Compare commits

..

342 Commits

Author SHA1 Message Date
Ali
33cc29fa3c fix(sidebar): set helper anchor color to match the other items [C9S-47] (#2058) 2026-03-16 15:50:59 +13:00
Chaim Lev-Ari
5e2eb667b4 fix(kube/app): enable edit button for regular apps [BE-12690] (#2039) 2026-03-15 11:22:09 +02:00
Ali
1f9c9b082f feat(policies): banner and confirmation on change policy [C9S-20] (#1988) 2026-03-13 14:11:53 +13:00
Cara Ryan
722c1875af chore(helm): upgrade sdk to v4 [R8S-840] (#2000) 2026-03-13 11:34:28 +13:00
Ali
68471d0225 fix(stacks): use widget-tabs consistently [c9s-33] (#2038) 2026-03-13 08:30:45 +13:00
Phil Calder
a6900545b0 Report a vulnerability via email or GitHub (#2037) 2026-03-12 12:30:40 +13:00
Chaim Lev-Ari
808ceba848 feat(docker): allow user to specify security-opts (#2022)
Co-authored-by: dylan <dfldylan@qq.com>
Co-authored-by: jerry-yuan <i@jerryzone.cn>
2026-03-11 08:56:42 +02:00
Oscar Zhou
a796a03a15 fix(edge/helm): helm edge stack is marked as external [BE-12653] (#1974) 2026-03-11 12:51:07 +13:00
andres-portainer
5a5dc67209 fix(golang-lru): consolidate the dependencies BE-12695 (#2021) 2026-03-10 18:57:49 -03:00
andres-portainer
69ae54b523 fix(zerolog): consolidate the dependencies BE-12695 (#2030) 2026-03-10 18:30:21 -03:00
andres-portainer
b405227d51 fix(jwt): consolidate the dependencies BE-12695 (#2020) 2026-03-10 15:14:21 -03:00
andres-portainer
44be39a9a4 fix(mapstructure): consolidate the dependencies BE-12695 (#2019) 2026-03-10 14:48:37 -03:00
andres-portainer
5de0cc199c fix(kingpin): consolidate dependencies BE-12695 (#2018) 2026-03-10 14:33:10 -03:00
andres-portainer
0c9e408eda fix(ldap): consolidate dependencies BE-12695 (#2017) 2026-03-10 14:18:06 -03:00
Chaim Lev-Ari
1007f1f740 feat(ui): create shared terminal component [BE-12697] (#1979) 2026-03-10 18:17:29 +02:00
Chaim Lev-Ari
774e3d5948 fix(ws): remove limit on docker console [BE-12660] (#2023) 2026-03-10 15:26:33 +02:00
andres-portainer
4d866d066a fix(uuid): consolidate dependencies BE-12695 (#2016) 2026-03-10 10:12:42 -03:00
andres-portainer
da6544e981 fix(semver): consolidate dependencies BE-12695 (#2014) 2026-03-09 15:33:45 -03:00
bernard-portainer
3af9a7646d fix(ui): add getRowId to expandable storage component [R8S-538] (#2008) 2026-03-09 15:37:40 +13:00
andres-portainer
0e2cf82e3e fix(yaml): consolidate dependencies BE-12695 (#2015) 2026-03-06 18:21:12 -03:00
andres-portainer
97e69b9887 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2011) 2026-03-06 14:29:15 -03:00
andres-portainer
692f91263b fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2010) 2026-03-06 11:23:52 -03:00
LP B
8b61d8a9d2 fix(app/container): query env registries instead of system registries (#1996) 2026-03-06 15:03:11 +01:00
LP B
25d51f9515 fix(app): paginate nested tables (#1998) 2026-03-06 15:01:52 +01:00
LP B
20b971dc1f fix(app/stack): virtual grouping in EnvSelector for non admins (#2001) 2026-03-06 15:00:01 +01:00
andres-portainer
7a76d749e3 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2003) 2026-03-06 09:47:20 -03:00
LP B
123afd9462 fix(api/custom_template): validate UAC when retrieving custom template file (#1980) 2026-03-04 13:22:14 +01:00
Xing
ad83478b77 fix(oauth): tolerate malformed Content-Type headers from resource ept (#1969)
Co-authored-by: Mike Spook <16549186+mikespook@user.noreply.gitee.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>

Thanks @srikanth-karthi for the original PR.
2026-03-02 10:59:02 +13:00
nickl-portainer
2ad0a65613 feat(policies): add inline editing ability to datatable for docker RBAC policies [R8S-717] (#1955) 2026-03-02 09:12:13 +13:00
Chaim Lev-Ari
1f5762b8c8 fix(settings/auth): fix a11y labels (#1963) 2026-03-01 12:14:47 +02:00
RHCowan
0370b09ad0 fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) 2026-02-27 11:45:15 +13:00
Oscar Zhou
5869a8948d refactor(stack): change stack creation flow to save stack first [BE-12650] (#1959) 2026-02-27 10:14:17 +13:00
Chaim Lev-Ari
56a840e207 feat(settings): migrate SessionLifetimeSelect to React [BE-12583] (#1829)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 15:39:08 +02:00
Chaim Lev-Ari
a01dd005fd refactor(settings/auth): migrate auto user provision toggle to react [BE-12585] (#1865)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 14:18:48 +02:00
Chaim Lev-Ari
9ad6c16d43 feat(settings): migrate authentication method selector to React [BE-12584] (#1830)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 10:52:39 +02:00
Hannah Cooper
9cc3e16db9 Update bug_report to include 2.39.0 (#1964) 2026-02-26 12:30:42 +13:00
andres-portainer
d02bcdba29 fix(postinit): optimize PostInitMigrate() BE-12659 (#1958) 2026-02-25 16:03:26 -03:00
Steven Kang
c708fe577c fix(kubernetes): local exec to fall back to SPDY - develop [R8S-873] (#1946) 2026-02-25 15:46:15 +13:00
Oscar Zhou
c92161bb22 feat(edge/helm): support per device configuration [BE-12633] (#1901) 2026-02-25 10:00:37 +13:00
Ali
138aa13fdc fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1954)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:16 +13:00
Steven Kang
988a795def fix(environment): collapsing More options breaking the style for podman - develop [R8S-874] (#1942) 2026-02-24 10:11:31 +13:00
Oscar Zhou
3f7a3053ff fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1940) 2026-02-24 08:41:42 +13:00
Oscar Zhou
0c8c6865be refactor(error): standardize multi errors handling [BE-12647] (#1933) 2026-02-23 09:40:01 +13:00
Chaim Lev-Ari
2bbcae39b6 feat: clean frontend test logs (#1894) 2026-02-22 09:42:49 +02:00
andres-portainer
caf6b2aa0c fix(policies): fixes for async edge R8S-661 (#1917) 2026-02-20 17:45:45 -03:00
Steven Kang
a00f05fe32 feat(environment): reorder options - develop [R8S-524] (#1822) 2026-02-20 14:58:01 +13:00
Chaim Lev-Ari
9fcac1ab4f chore(deps): upgrade axios [BE-12632] (#1864) 2026-02-19 15:38:08 +13:00
Josiah Clumont
ae24ad4693 Bump version to 2.39.0 for LTS (#1910) 2026-02-19 15:29:08 +13:00
RHCowan
0f721b60a9 fix(policy) Improve policy status performance [R8S-710] (#1878) 2026-02-19 15:24:14 +13:00
RHCowan
e8b49f53e1 fix(policy) fix policy group pagination issues [R8S-855] (#1898) 2026-02-19 13:29:01 +13:00
andres-portainer
27531a802b fix(fips): ensure custom registries cannot use HTTP without TLS BE-12511 (#1885)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-02-19 11:51:11 +13:00
Josiah Clumont
4bbf0ce0c0 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.39.0-LTS [R8S-818] (#1791) 2026-02-19 09:46:14 +13:00
Josiah Clumont
e0c22ea3eb fix(copy): Fixed an issue with the downgrade links [R8S-832] (#1907) 2026-02-19 09:38:04 +13:00
nickl-portainer
b7eb2ba068 fix(policies) convert all warnings to use PolicyOverrideAlert [R8S-837] (#1890) 2026-02-19 09:12:54 +13:00
Ali
affdb69568 fix(policies): show registry policy banner, and disable registry selector when policy applies [R8S-853] (#1891) 2026-02-19 08:37:02 +13:00
LP B
763b7da65c fix(api/docker): do not rewrite HTTP code in responses of create requests (#1854) 2026-02-18 19:26:29 +01:00
Chaim Lev-Ari
42e9165347 fix(stacks): generate webhook id for stacks (#1876) 2026-02-17 10:38:18 +02:00
Ali
16dd08a359 feat(widget): update widget tab styling product wide [r8s-850] (#1881) 2026-02-17 10:33:43 +13:00
Ali
936494615c fix(select): stop react-select overlapping with footer [R8S-794] (#1880) 2026-02-17 08:53:50 +13:00
andres-portainer
5769c0b98e fix(kubernetes): add missing returns BE-12582 (#1883) 2026-02-16 12:47:27 -03:00
andres-portainer
b7e1caa8c6 fix(boltdb): fix error handling BE-12582 (#1882) 2026-02-16 12:47:00 -03:00
andres-portainer
e02ae6b2fb fix(archive): prevent file traversal vulnerability BE-12582 (#1875) 2026-02-16 11:26:51 -03:00
testA113
d9f131a2c5 Revert "feat(widget): update widget tab styling product wide [r8s-850]"
This reverts commit d882c3b8fa4a03bf85b4e9fb1da729fabf903cb6.
2026-02-17 00:05:24 +13:00
testA113
ad1f7dbaa5 feat(widget): update widget tab styling product wide [r8s-850] 2026-02-17 00:01:07 +13:00
Devon Steenberg
aa6da0f6d3 feat(api-testing): add api testing framework [BE-12571] (#1824)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-02-16 09:35:06 +13:00
Oscar Zhou
376071e408 feat(edge/helm): add atomic and timeout options [BE-12481] (#1849) 2026-02-16 09:21:19 +13:00
Chaim Lev-Ari
d3544fb9b3 refactor(tests): mock ws server (#1853) 2026-02-15 08:58:24 +02:00
Chaim Lev-Ari
c8497b3944 chore(deps): upgrade html-loader (#1863) 2026-02-15 08:55:33 +02:00
andres-portainer
5aa92b8413 fix(webhooks): use transactions to check for webhook uniqueness BE-12613 (#1872) 2026-02-13 12:48:17 -03:00
Hannah Cooper
bccb6694d4 Update bug_report to include 2.38.1 (#1866) 2026-02-13 12:42:08 +13:00
Hannah Cooper
506a11c658 Update bug_report to include 2.33.7 (#1836) 2026-02-13 12:28:05 +13:00
Ali
bdc315a59d fix(helm): helm release not found error [r8s-842] (#1857) 2026-02-13 08:07:23 +13:00
andres-portainer
ec7d3bddfc fix(endpoints): fix transaction usage BE-12612 (#1838) 2026-02-11 12:34:46 -03:00
Chaim Lev-Ari
762c1ccf28 chore(deps): upgrade vitest and msw (#1852) 2026-02-11 17:14:04 +02:00
Malcolm Lockyer
8e44c8fa06 fix(webpack): fix common cfg after webpack-dev-server upgrade [r8s-841] (#1848) 2026-02-11 18:34:14 +13:00
Chaim Lev-Ari
20db102327 chore(deps): upgrade webpack (#1802) 2026-02-10 18:01:03 +02:00
Chaim Lev-Ari
1643cb8165 fix(environments): handle unix:// urls [BE-12610] (#1837)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-02-10 15:21:25 +02:00
Ali
49e623dfeb feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) 2026-02-10 23:44:44 +13:00
Steven Kang
a1208974ac fix(policy): pod security constraints - develop [R8S-808] (#1758)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-02-10 08:46:02 +09:00
Chaim Lev-Ari
d611087513 chore(deps): upgrade storybook 8 (#1811) 2026-02-08 09:59:08 +02:00
andres-portainer
ac7cb2ee19 fix(security): fix CVE-2025-68121 by upgrading Go compiler BE-12581 (#1813) 2026-02-06 13:17:12 -03:00
Oscar Zhou
f866572cbf fix(edge/helm): helm config section shows for other type [BE-12580] (#1808) 2026-02-06 09:13:06 +13:00
Chaim Lev-Ari
4c6942f60b fix(environments): update associated group [BE-12559] (#1760) 2026-02-05 18:48:02 +02:00
nickl-portainer
d939897524 feat(menu) add policies to environment settings submenu [R8S-806] (#1805) 2026-02-05 14:39:41 +13:00
nickl-portainer
66c5589fd7 fix(environment-list) resize kubeconfig download modal [R8S-814] (#1786)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-02-05 14:39:23 +13:00
Oscar Zhou
379b1d611b feat(edge/helm): support helm chart via git repository in edge stack [BE-12448] (#1649) 2026-02-05 13:22:31 +13:00
Chaim Lev-Ari
f16221f385 docs(claude): optimize memory files (#1777) 2026-02-05 04:28:36 +05:30
RHCowan
9b82560270 fix(policy) Fetch new status after policy update [R8S-711] (#1775) 2026-02-04 18:23:26 +13:00
Oscar Zhou
7271af03e6 fix(docker): dashboard api return 500 error [BE-12567] (#1784) 2026-02-04 08:32:01 +13:00
RHCowan
4d564bbce2 feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) 2026-02-03 12:32:22 +13:00
Oscar Zhou
d7afdf214b refactor(k8s): replace kubectl delete with delete api [BE-12560] (#1768) 2026-02-03 08:36:08 +13:00
Chaim Lev-Ari
18e445ea02 refactor(environments): migrate item view to react [BE-6632] (#1747) 2026-01-31 15:05:11 +07:00
nickl-portainer
cb70c705a3 fix(react): namespace selects sort alphabetically [R8S-765] (#1671) 2026-01-30 08:23:01 +13:00
Ali
9a77eb9872 chore(environment-groups): migrate environment groups to react [R8S-771] (#1741) 2026-01-29 14:17:33 +13:00
Hannah Cooper
ec82f646a0 Add 2.38.0 to bug report (#1756) 2026-01-29 12:45:23 +13:00
andres-portainer
2f0e384240 fix(database): use Exists() where possible to improve performance BE-12557 (#1752) 2026-01-28 18:49:32 -03:00
Ali
19a1426869 chore(webpack): cache dependencies and use lighter sourcemap [R8S-791] (#1715) 2026-01-29 09:52:11 +13:00
andres-portainer
cc5cd8db6b fix(pendingactions): clean up and optimize the code BE-12556 (#1750) 2026-01-28 15:36:54 -03:00
andres-portainer
e384e2edda fix(pendingactions): fix transaction handling BE-12556 (#1749) 2026-01-28 14:11:35 -03:00
Chaim Lev-Ari
dca044873f feat(environments): migrate edge form to react BE-12529 (#1676) 2026-01-28 15:35:13 +07:00
nickl-portainer
8aadddcc68 test(react): add test coverage for forms to enforce no errors showing on initial load [R8S-730] (#1696) 2026-01-28 08:12:12 +13:00
andres-portainer
2e95229c51 fix(oauth): add a timeout to GetResource() BE-12258 (#1456) 2026-01-27 10:24:45 -03:00
Phil Calder
8a1d02c23f Bump version to 2.38.0 (#1727) 2026-01-27 16:26:14 +13:00
Josiah Clumont
d6bca4ea79 chore(icon): Update sidebar icon & favicon to align with branding (#1737) 2026-01-27 15:11:28 +13:00
LP B
7b567a66ed fix(app/stack): remove unauthorizedRedirect from stack details view (#1720) 2026-01-26 22:21:41 +01:00
Chaim Lev-Ari
2c8126e244 refactor(environments): migrate general environment form to react (#1706) 2026-01-26 14:40:01 -03:00
Chaim Lev-Ari
1b70fe5770 feat(registries): enable ecr registry for fips BE-12539 (#1665) 2026-01-26 14:38:57 -03:00
andres-portainer
71c000756b chore(linters): enforce error checking in CE BE-12527 (#1723) 2026-01-26 14:37:55 -03:00
Yajith Dayarathna
a2a7ead82a chore(ci): updates to pnpm lint and gofmt (#1730) 2026-01-27 06:14:20 +13:00
Malcolm Lockyer
ef0f1b10cc fix(database): fix encryption of existing database [r8s-537] (#1663)
Co-authored-by: Gorbasch <mbegerau@users.noreply.github.com>
2026-01-25 17:45:38 +13:00
RHCowan
42bedce9c0 feat(policy) add policy status filter to endpoint list [R8S-736] (#1682) 2026-01-23 12:03:05 +13:00
Devon Steenberg
afcd44abad fix(kubectl-shell): enable kubectl shell in fips mode [BE-12422] (#1702)
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
2026-01-23 09:38:26 +13:00
Josiah Clumont
274830f533 fix(policy): Policy status bar doesn't use correct colours (#1714) 2026-01-23 08:12:45 +13:00
Ali
9cb139d190 fix(access): handle access view loading and error states [R8S-779] (#1709) 2026-01-22 13:04:43 +13:00
Josiah Clumont
d681481ae9 feat(policy): rework the environment type row in the policy view [R8S-695] (#1698) 2026-01-22 09:43:55 +13:00
Oscar Zhou
5d377e602f fix(edgestack): EntryFileName not found [BE-12499] (#1578) 2026-01-22 08:44:31 +13:00
Ali
f535c814d9 feat(policies): UI stepper in policy create and environment wizard [R8S-718] (#1672) 2026-01-21 09:37:39 +13:00
andres-portainer
4f5073cd9e chore(refactor): clean up the code R8S-661 (#1687) 2026-01-16 16:10:00 -03:00
LP B
9cd2340007 fix(app/home): display API error message instead of generic error when env is unreachable (#1670) 2026-01-16 14:38:28 +01:00
Chaim Lev-Ari
9ca036e393 feat(pnpm): add system-tests to workspace PLA-567 (#1664) 2026-01-15 12:45:23 +02:00
andres-portainer
5340ecb6df refactor(stackutils): consolidate validation code BE-12391 (#1667) 2026-01-14 18:00:01 -03:00
Chaim Lev-Ari
1248d52161 refactor(environment): migrate azure form to react BE-12528 (#1642)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 18:20:15 +02:00
andres-portainer
3e2fdb1891 fix(swarm): fix environment security checks BE-12541 (#1666) 2026-01-14 12:25:50 -03:00
andres-portainer
ac8fa7672e fix(environments): improve the default environment security settings BE-12391 (#1656) 2026-01-14 10:36:42 -03:00
LP B
db57716130 fix(api): remove overly verbose log on startup (#1655) 2026-01-13 19:39:35 +01:00
LP B
b162814bd9 fix(uac): async SnapshotRaw data not filtered by UAC (#1540) 2026-01-13 17:17:06 +01:00
LP B
a889d57013 fix(app/edge): UI form error on edge stack update (#1643) 2026-01-13 17:15:51 +01:00
Chaim Lev-Ari
c6e9cdbf35 fix(stacks): save registries when creating stack BE-12526 (#1633) 2026-01-13 09:00:48 +02:00
Phil Calder
2a00d90134 chore(docs): Adds a SECURITY.md to repos (#1636)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 13:07:50 +13:00
andres-portainer
2676cd7219 chore(linters): add the unused, zerologlint and exptostd linters BE-12527 (#1645) 2026-01-12 10:28:17 -03:00
Chaim Lev-Ari
4f76b1fda4 refactor(environments): prepare common fields for edit env form BE-12531 (#1641)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 19:01:28 +02:00
Chaim Lev-Ari
1c56d5c59e fix(environments): fix issues in edit page (#1640) 2026-01-09 16:41:39 +02:00
Chaim Lev-Ari
be44eedeb8 feat(environments): migrate KubeConfigInfo to React (PR 8 of 10) [BE-12524] (#1625)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:37:21 +02:00
Chaim Lev-Ari
36296d2f5d fix(docker/configs): delete config from item view BE-12525 (#1628) 2026-01-09 14:36:24 +02:00
andres-portainer
b4db75fb55 chore(linters): add the unconvert linter BE-12527 (#1635) 2026-01-09 09:22:13 -03:00
Chaim Lev-Ari
565c36040d feat(environments): migrate edge agent deployment to React [BE-12522] (#1626)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 13:32:05 +02:00
Ali
36e7f821e8 fix(namespace): fix namespace user access calls and parsing [r8s-726] (#1610) 2026-01-09 13:15:57 +13:00
Ali
009e1e25f5 fix(k8s deploy): ensure namespace from deploy form/api call can be used [r8s-747] (#1632) 2026-01-09 12:57:03 +13:00
Ali
69715ed1c8 fix(helm): avoid widget title error thrown for helm edit/upgrade [r8s-746] (#1630) 2026-01-09 10:25:51 +13:00
andres-portainer
e8cee12384 chore(linters): add the modernize linter BE-12527 (#1634) 2026-01-08 16:35:18 -03:00
andres-portainer
f2fd2c157c chore(errcheck): ensure errcheck scans everything BE-12183 (#1094) 2026-01-08 14:41:40 -03:00
Chaim Lev-Ari
3f6cee5ded feat(portainer): migrate EdgeInformationPanel to React BE-12521 (#1624)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 15:27:27 +02:00
Devon Steenberg
b1cb95c3b0 fix(docker): bump docker max api version [BE-12462] (#1556) 2026-01-08 14:22:48 +13:00
LP B
372bc3c97c fix(app): generate a container name when names list is empty (#1615) 2026-01-07 20:20:28 +01:00
Chaim Lev-Ari
fa684f95e0 feat(portainer): migrate Environment basic config section to React BE-12520 (#1620) 2026-01-07 18:37:19 +02:00
Chaim Lev-Ari
e8fb8a6f88 feat(portainer): migrate AzureEndpointConfigSection to React BE-12519 (#1619)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 17:36:52 +02:00
andres-portainer
93901336bb fix(git): upgrade go-git to v5.16.4 BE-12512 (#1607) 2026-01-07 09:18:21 -03:00
RHCowan
660f2095af fix(policy) Show all policy types in selector [R8S-735] (#1591) 2026-01-07 19:12:30 +13:00
Ali
13b27cf77a feat(aci): environment variable support [r8s-675] (#1445)
Merging because the playwright tests don't relate to the container instance changes in this PR
2026-01-07 15:49:54 +13:00
Oscar Zhou
d1eb5a8466 fix(stack/k8s): kubectl command memory leak [BE-12455] (#1582) 2026-01-07 11:51:28 +13:00
andres-portainer
5d0aefb07a fix(registryproxy): consolidate the TLS initialization code BE-12511 (#1601) 2026-01-06 10:59:38 -03:00
andres-portainer
78a23bb722 fix(frontend): update dependencies to fix vulnerabilities BE-12506 (#1595)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-01-06 10:58:46 -03:00
Chaim Lev-Ari
38c42cb47b refactor(containers): migrate container item view to react BE-6582 (#1606)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 12:33:24 +02:00
Chaim Lev-Ari
c9c779d5d5 refactor(containers): migrate volume section to react BE-12495 (#1605) 2026-01-06 10:18:51 +02:00
Chaim Lev-Ari
dabfd4249e refactor(containers): migrate container details section to react BE-12494 (#1602) 2026-01-06 08:05:30 +02:00
Ali
e62db5f1d9 chore(pre-commit hooks): allow golangci-lint to run concurrently for CE and EE for pre commit hook [R8S-737] (#1608) 2026-01-06 16:57:03 +13:00
Chaim Lev-Ari
50c01c97ee fix(proxy): add error handler to print error to user (#1593) 2026-01-05 14:40:35 +02:00
andres-portainer
68600dddf0 fix(security): fix a nil pointer dereference error in FilterEndpoints() BE-12509 (#1598) 2026-01-02 16:08:17 -03:00
andres-portainer
c80464d072 fix(edgegroups): fix a nil pointer dereference BE-12487 (#1573) 2026-01-02 15:26:53 -03:00
andres-portainer
02a083fa02 fix(filesystem): fix a nil pointer dereference error in CopyPath() BE-12508 (#1597) 2026-01-02 15:18:21 -03:00
andres-portainer
36ff24c301 fix(endpointgroups): fix a nil pointer dereference error in deleteEndpointGroup BE-12510 (#1599) 2026-01-02 15:17:51 -03:00
Chaim Lev-Ari
935f3b8754 refactor(containers): migrate image section to react BE-12493 (#1594) 2026-01-01 11:12:05 +02:00
Chaim Lev-Ari
eac9f649cf chore(build): introduce pnpm workspaces (#1584) 2025-12-31 18:52:58 +02:00
Chaim Lev-Ari
8bcd27e042 refactor(containers): migrate status section to react BE-12492 (#1583) 2025-12-31 10:12:37 +02:00
Chaim Lev-Ari
c3dbf51a16 feat(docker): migrate ContainerActionsSection to React (PR 2 of 7) (#1576) 2025-12-30 11:41:49 +02:00
Chaim Lev-Ari
36417a0726 chore(build): migrate to pnpm (#1558) 2025-12-29 10:14:57 +02:00
Yajith Dayarathna
20b87f8bb9 fix(build): adding fixes for docker buildx build warnings in ci (#1567) 2025-12-29 10:31:51 +13:00
Chaim Lev-Ari
a1bac5a133 refactor(stacks): migrate create view to react [BE-6630] (#1538) 2025-12-26 16:50:55 +02:00
Chaim Lev-Ari
177da24e47 feat(docker): migrate RestartPolicySection to React BE-12490 (#1570) 2025-12-24 18:38:52 +02:00
Chaim Lev-Ari
37ba8d17bf fix(stacks): confirm rename with modal BE-12497 (#1571) 2025-12-24 17:45:27 +02:00
andres-portainer
ee8b78fd3c chore(segmentio/encoding): upgrade to v0.5.3 BE-12500 (#1575) 2025-12-24 12:09:01 -03:00
Chaim Lev-Ari
83bc685e75 fix(stacks): allow renaming stack in swarm BE-12496 (#1572) 2025-12-24 16:41:37 +02:00
andres-portainer
3781897e39 fix(compose): upgrade compose-go to v2.40.3 to fix a nil panic BE-12424 (#1550) 2025-12-23 22:26:25 -03:00
Chaim Lev-Ari
0efed6d8d3 fix(stacks): invalidate only stack cache on update BE-12476 (#1566) 2025-12-23 15:27:26 +02:00
Chaim Lev-Ari
8f2c33aec3 chore(node): upgrade node version in CI [BE-12465] (#1525) 2025-12-23 10:22:48 +02:00
Chaim Lev-Ari
433b5bc974 fix(ci): run eslint and typecheck without symlinks (#1564) 2025-12-22 17:38:42 +02:00
Chaim Lev-Ari
aef27f475d feat(analytics): remove setting for collection analytics [BE-12402] (#1559) 2025-12-22 15:59:08 +02:00
Viktor Pettersson
28ccf19874 fix(docs): ensure all docs related dependencies, such as struct types are available before building swagger docs PLA-542 (#1562) 2025-12-22 15:02:56 +13:00
Yajith Dayarathna
7e54f40033 chore: ci workflow(round3) and Dockerfile update (#1542) 2025-12-22 10:54:51 +13:00
Chaim Lev-Ari
bf8ccbcec6 Revert "feat(frontend): import CE code to EE" (#1557) 2025-12-18 13:45:26 +02:00
Chaim Lev-Ari
2f5b083c5c feat(frontend): import CE code to EE (#1365) 2025-12-17 13:02:19 +02:00
James Carppe
5640e8c11a Version bump for 2.33.6 (#1548) 2025-12-17 18:25:29 +13:00
Devon Steenberg
c239445454 fix(swarm): stack deployments [BE-12478] (#1546)
This commit 9b9d103b29, introduced in docker 29, changed the behaviour of how the --tlsXXX flags are handled. Before this change leading and trailing quotes would be stripped. This meant that an invalid path that we were passing for the tls ca cert was being cleaned up to be an empty string. To preserve the old behaviour we now pass an empty string.
2025-12-17 14:21:49 +13:00
Chaim Lev-Ari
a7b7ddbe76 fix(containers): clear mac address on edit/duplicate [BE-12436] (#1524) 2025-12-15 09:59:47 +02:00
andres-portainer
d859272d43 chore(compress): upgrade klauspost/compress to v1.18.2 (#1534) 2025-12-12 12:30:00 -03:00
Oscar Zhou
d59a16a9a1 fix(stack): stack start failed with private image [BE-12464] (#1523) 2025-12-12 10:55:03 +13:00
andres-portainer
79f524865f fix(yaml): switch from gopkg.in/yaml.v3 to go.yaml.in/yaml/v3 BE-12340 (#1527) 2025-12-11 16:44:56 -03:00
Chaim Lev-Ari
6d0a09402b refactor(stacks): migrate item view to react [BE-6629] (#1444) 2025-12-11 10:21:43 +02:00
Steven Kang
4bb160b281 fix(security): cve-2025-47914 and 58181 - develop [R8S-714] (#1516) 2025-12-11 15:22:22 +09:00
Hannah Cooper
24d27f421b Update bug_report to include 2.37.0 (#1518) 2025-12-11 12:41:05 +13:00
Chaim Lev-Ari
3d0b8ec5f0 feat(update): prevent the creation of updater network [BE-12441] (#1517) 2025-12-10 18:45:46 +02:00
Chaim Lev-Ari
79e6271041 refactor(docker/images): migrate list view to react [BE-6562] (#1451) 2025-12-09 15:27:20 +02:00
Chaim Lev-Ari
ecac526810 feat(analytics): remove frontend analytics module (#1459) 2025-12-09 09:27:51 +02:00
Oscar Zhou
ad8d5a8694 version: bump version to 2.37.0 (#1501) 2025-12-09 13:06:50 +13:00
Steven Kang
2406d67bfc feat(fcm): initial release (#1153)
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Viktor Pettersson <viktor.grasljunga@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
Co-authored-by: Robbie Cowan <robert.cowan@portainer.io>
2025-12-09 08:05:38 +09:00
Oscar Zhou
f0266e9316 fix(stack/remote): fail to pull image in stack with relative path enabled [BE-12237] (#1493) 2025-12-09 08:59:19 +13:00
Chaim Lev-Ari
c08f42315e feat(docker/host): disable browse for non admin [BE-12438] (#1484) 2025-12-08 16:51:52 -03:00
Chaim Lev-Ari
d2649dac90 fix(docker/services): ignore missing EndpointSpec [BE-12460] (#1494) 2025-12-08 16:51:18 -03:00
LP B
300681055e fix(api): do not give away information on error (#1496) 2025-12-08 16:50:00 -03:00
andres-portainer
712dbc9396 fix(endpointedge): reject async edge environments from the edge job logs handler BE-12372 (#1488) 2025-12-08 15:05:32 -03:00
andres-portainer
f6b8e8615f fix(endpointedge): fix an incorrect documentation comment BE-12372 (#1486) 2025-12-08 11:59:53 -03:00
andres-portainer
4826c13848 fix(endpointedge): add a check for the relation of an environment and an edge job before updating the logs BE-12372 (#1487) 2025-12-08 11:59:40 -03:00
Yajith Dayarathna
80f497a185 chore(ci): minor ci workflow updates (#1491) 2025-12-08 14:12:24 +13:00
LP B
d2a9adb4be fix(compose): use project in compose start options (#1477) 2025-12-05 15:22:40 +01:00
Oscar Zhou
8675086441 fix(stack): "update the stack" button is disable in stakc deployed via web editor [BE-12456] (#1473) 2025-12-05 08:56:13 +13:00
Devon Steenberg
b79e784764 fix(stacks): stack updating with container_name [BE-12443] (#1453) 2025-12-02 09:32:03 +13:00
Chaim Lev-Ari
93ba3e700e fix(ui/code-editor): keep search panel in editor layer [BE-12429] (#1452) 2025-11-27 14:32:57 +02:00
Chaim Lev-Ari
bf6cb8d0b8 refactor(stacks): use formik in StackRedeployGitForm [BE-12430] (#1433) 2025-11-27 08:43:51 +02:00
Hannah Cooper
7010d7bf66 Update bug_report to include 2.33.5 and 2.36.0 (#1447) 2025-11-27 10:35:38 +13:00
Oscar Zhou
1a862157a0 fix(snapshot): prevent from returning SnapshotRaw data [BE-12431] (#1441) 2025-11-26 13:07:43 +13:00
Chaim Lev-Ari
532575cab5 refactor(stacks): migrate info tab to react [BE-12383] (#1415) 2025-11-25 13:17:26 +02:00
Chaim Lev-Ari
0794d0f89f refactor(docker/configs): migrate to react [BE-6541] (#1430) 2025-11-25 12:02:50 +02:00
Chaim Lev-Ari
e227ffd6d8 feat(stacks): create webhook id only if needed [BE-12392] (#1432) 2025-11-25 10:48:15 +02:00
Devon Steenberg
5058b40871 chore(version): bump to v2.36.0 (#1434) 2025-11-25 11:09:49 +13:00
Chaim Lev-Ari
5d847b59b2 feat(analytics): remove matomo dependency [BE-12404] (#1431) 2025-11-24 16:30:03 +02:00
Oscar Zhou
c8d44b9416 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1428) 2025-11-22 09:04:31 +13:00
Oscar Zhou
14d67d1ec7 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1385) 2025-11-21 12:44:42 +13:00
Hannah Cooper
6866faf4fe Update bug_report to include 2.33.4 (#1420) 2025-11-20 13:06:07 +13:00
Viktor Pettersson
567d628a52 fix(edge-stacks): inconsistent edge stack count BE-12285 (#1382) 2025-11-20 10:56:38 +13:00
Chaim Lev-Ari
a3eab75405 refactor(registries): remove superfluous useEffect in PrivateRegistryFieldset [BE-12408] (#1396) 2025-11-19 08:12:11 +02:00
Chaim Lev-Ari
566f6b067c fix(environments): fix podman auto onboarding script [BE-12327] (#1395) 2025-11-18 14:30:23 +02:00
Chaim Lev-Ari
e73d07281c fix(endpoints): Change syntax for multi-line commands in Windows (#1355)
Co-authored-by: Shawn <host@shawnsg.dev>
2025-11-18 08:48:32 +02:00
Steven Kang
e59d4dea77 fix: CVE-2024-25621 - develop [R8S-639] (#1412) 2025-11-18 17:34:10 +13:00
Steven Kang
4ca5370b86 fix: CVE-2025-47913 - develop [R8S-638] (#1401) 2025-11-18 16:28:14 +13:00
Devon Steenberg
e831971dd1 fix(docker): bump docker max api version [BE-12399] (#1392) 2025-11-18 11:27:16 +13:00
Steven Kang
99d996dde9 fix: CVE-2025-47906 and CVE-2025-47910 - develop [R8S-618] (#1389) 2025-11-18 08:57:00 +13:00
Malcolm Lockyer
712d31b416 fix(agent): for iamra and ecr login, detect errors and retry [be-12284] (#1362) 2025-11-17 11:51:09 +13:00
Steven Kang
0394855b2f feat: reorder environment creation types (#1359) 2025-11-17 10:09:19 +13:00
Chaim Lev-Ari
9024b021ee feat(environments): deprecate openamt [BE-12359] (#1390) 2025-11-16 09:55:00 +02:00
Chaim Lev-Ari
8071641179 refactor(stacks): convert editor to tab (#1374) 2025-11-12 15:44:13 +02:00
Chaim Lev-Ari
0075374241 fix(ui/datatables): show selected filter values [BE-11301] (#1387) 2025-11-12 15:21:17 +02:00
Chaim Lev-Ari
c35ddc8c76 feat(git): hide user/pass for save creds [BE-10953] (#1376) 2025-11-12 15:20:20 +02:00
Oscar Zhou
4b4aef7ef8 fix(stack): apply new stack manual redeployment filed name to regular stack [BE-12384] (#1375) 2025-11-12 09:17:57 +13:00
Copilot
6db4a62e01 Fix swagger enum issues causing duplicate constants in generated code (#1373)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: deviantony <5485061+deviantony@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
2025-11-12 08:45:08 +13:00
Chaim Lev-Ari
db394b6145 feat(logs): filter activity logs by envs and users [BE-12275] (#1383) 2025-11-11 14:49:26 +02:00
Chaim Lev-Ari
53e7704724 feat(stacks): allow to rename stacks [BE-12317] (#1339) 2025-11-09 09:39:29 +02:00
Chaim Lev-Ari
f607c7c271 reactor(stacks): migrate deploy git to react [BE-12382] (#1372) 2025-11-09 09:36:06 +02:00
Oscar Zhou
48c689e5d6 fix(registry): custom registry configure page doesn't reflect actual setting [BE-12385] (#1378) 2025-11-08 10:13:00 +13:00
Oscar Zhou
2f2251ff33 fix(registry): pulling private image from registry fails despite credential is valid [BE-12237] (#1303) 2025-11-08 10:12:17 +13:00
Devon Steenberg
29254d1a66 fix(proxy): replace Director with Rewrite field [BE-12328] (#1358) 2025-11-05 10:57:01 +13:00
Chaim Lev-Ari
19cbae1732 feat(registries): check dockerhub credentials [BE-12329] (#1338) 2025-11-04 18:46:37 +02:00
Chaim Lev-Ari
73ad27640c refactor(stacks): migrate duplication form to react [BE-12353] (#1357) 2025-11-04 18:44:54 +02:00
Chaim Lev-Ari
1be96e1bd1 fix(telemetry): update privacy policy url [BE-12350] (#1348) 2025-11-04 14:25:03 +02:00
Chaim Lev-Ari
a9834be2ff fix(widget): remove fixed margin on button [BE-12344] (#1346) 2025-11-04 14:24:26 +02:00
Chaim Lev-Ari
d8ab86d86f fix(templates): keep icon to their border size [BE-12349] (#1343) 2025-11-04 14:23:56 +02:00
Chaim Lev-Ari
3f1bd8e290 fix(ui): fix warnings in client-side tests [BE-12351] (#1342) 2025-11-04 14:23:11 +02:00
Chaim Lev-Ari
34a7d75e10 fix(edge-scripts): add podman auto onboarding script [BE-12327] (#1333) 2025-11-04 14:21:37 +02:00
Oscar Zhou
ae53de42df fix(stack): stack prune service does not persist [BE-12314] (#1323) 2025-11-03 12:22:04 +13:00
Oscar Zhou
b70321a0aa fix(edgestack): unify gitops update flow [BE-12184] (#1110) 2025-11-01 20:20:51 +13:00
Oscar Zhou
0ff39f9a61 refactor(stack): move stack update into transaction [BE-12244] (#1324) 2025-10-31 17:19:56 +13:00
Ali
876ba0fa0f fix: add titles to truncated text [r8s-610] (#1331)
Small behavioral change
2025-10-30 16:43:15 +13:00
Hannah Cooper
c7c65d2f97 Update bug_report to include 2.33.3 (#1352) 2025-10-30 15:18:48 +13:00
andres-portainer
736f7e198f fix(CVE-2025-62725): upgrade github.com/docker/compose/v2 to v2.40.2 BE-12352 (#1345) 2025-10-29 18:17:46 -03:00
Viktor Pettersson
8cb3589fb8 chore(go.mod): pin github.com/robfig/cron/v3 to v3.0.1 due to lack of maintenance BE-12226 (#1334) 2025-10-24 10:00:09 +13:00
Chaim Lev-Ari
56530d8791 fix(sidebar): add copyright icon to CE (#1325) 2025-10-23 18:14:09 +03:00
Chaim Lev-Ari
da6b0e3dcc refactor(registries): convert docker hub form to react (#1335) 2025-10-23 17:00:49 +03:00
Steven Kang
eb02f99cae feat: crds support [r8s-580] (#1254)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-10-23 11:07:03 +13:00
Chaim Lev-Ari
cb0efae81c chore(gitops): upgrade parse-duration dep [r8s-608] (#1328) 2025-10-22 13:20:20 +03:00
Viktor Pettersson
e5f98e6145 test(scheduler): use synctest to cut execution time by 95% BE-12226 (#1330) 2025-10-22 10:48:12 +13:00
Devon Steenberg
8a23007ad2 fix(deps): update github.com/container/image/v5 dep [BE-12212] (#1313) 2025-10-20 15:47:46 +13:00
Oscar Zhou
592b196848 fix(registry): selecting one item checked all items in registry access table [BE-12036] (#1318) 2025-10-20 12:55:32 +13:00
Ali
8eb273e54b docs(kubernetes): update Helm install docs link to /user/kubernetes/applications/manifest/helm [R8S-601] (#1317)
Minor docs change
2025-10-20 09:33:07 +13:00
Ali
78c7e752f9 chore(build): fix relative paths for make dev [r8s-588] (#1314) 2025-10-17 10:40:23 +13:00
Hannah Cooper
7c51a3b5ff Update bug report to include 2.35.0 (#1310) 2025-10-16 12:18:34 +13:00
Viktor Pettersson
3e77db4cee chore(version): bump to v2.35.0 (#1304) 2025-10-15 15:35:33 +13:00
Steven Kang
c1c831fea3 feat: gitops for Helm [r8s-343] (#1252)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2025-10-15 11:36:20 +13:00
Steven Kang
6734eab555 fix: add web socket headers for kubeconfig based access - develop [r8s-592] (#1288) 2025-10-10 13:41:07 +13:00
Viktor Pettersson
6ecfbf17c0 fix(autopatch): remove auto-patch feature flag BE-12086 (#1189) 2025-10-10 09:23:47 +13:00
Ali
42fe068db7 fix(security): fix typos in security policy [r8s-573] (#1278)
Co-authored-by: timbretimber <105982513+timbretimber@users.noreply.github.com>
2025-10-09 12:25:11 +13:00
Steven Kang
6b3db56ab2 fix: display dependency version for kubectl and helm - develop [R8S-501] (#1281) 2025-10-07 16:23:47 +13:00
Ali
eee15d5ff2 chore(dev): update build scripts to support mac (darwin) [r8s-588] (#1279) 2025-10-07 13:36:17 +13:00
andres-portainer
7a618311d6 feat(boltdb): attempt to compact using a read-only database BE-12287 (#1267) 2025-09-30 19:10:20 -03:00
Oscar Zhou
7dba9ff885 fix(k8s): memory leak during k8s stack deployment [BE-12281] (#1266) 2025-10-01 08:33:01 +13:00
James Carppe
4c9c292316 Version bump for 2.33.2 (#1259) 2025-09-25 17:32:58 +12:00
James Player
00613efbd8 fix(kubernetes UI): Update ingress cache after updating (#1247) 2025-09-25 11:26:36 +12:00
andres-portainer
b7384874cf feat(database): add a flag to compact on startup BE-12283 (#1255) 2025-09-24 18:44:09 -03:00
Ali
c8ee2ca4a1 fix(rbac): redirect on unauthorized namespace [r8s-564] (#1244) 2025-09-24 22:09:28 +12:00
andres-portainer
f97bb4a439 fix(edgestacks): add a missing webhook uniqueness check BE-12219 (#1250) 2025-09-23 17:21:13 -03:00
LP B
d83b349016 fix(api/endpoints): edge stack status type filter no longer always include Pending envs (#1229) 2025-09-22 16:10:39 +02:00
Ali
657cd04af2 fix(cve): fix frontend CVEs [r8s-563] (#1239) 2025-09-22 10:15:29 +12:00
Oscar Zhou
24a092836b fix(activitylog): remove export limit and fix search function [BE-12270] (#1235) 2025-09-19 14:52:33 +12:00
andres-portainer
290374f6fc fix(kubernetes/cli): unexport a field BE-12259 (#1228) 2025-09-18 14:39:38 -03:00
andres-portainer
2e7acc73d8 fix(kubernetes/cli): fix a data-race BE-12259 (#1218) 2025-09-18 09:19:29 -03:00
Oscar Zhou
666d51482e fix(container): apply less accurate solution to calculate container status for swarm environment [BE-12256] (#1225) 2025-09-18 16:29:35 +12:00
Oscar Zhou
eedf37d18a feat(edge): add option to allow always clone git repository [BE-12240] (#1215) 2025-09-17 18:25:42 +12:00
Viktor Pettersson
16f210966b fix(version): change API version support from LTS to STS (#1223) 2025-09-17 17:18:03 +12:00
andres-portainer
30e70b6327 chore(version): bump to v2.34.0 (#1216) 2025-09-15 22:13:51 -03:00
andres-portainer
f91a2e3b65 fix(csp): update the Content-Security-Policy header BE-12228 (#1201) 2025-09-15 10:47:50 -03:00
Ali
fdc405c912 feat(docker-networks): allow ipv6 for ipvlan networks [portainer-pr12608] (#1196)
Co-authored-by: ar0311 <arogers0311@gmail.com>
2025-09-15 11:49:06 +12:00
Phil Calder
2f2e70bb86 Fix typo (#1186) 2025-09-13 14:31:52 +12:00
andres-portainer
eef54f4153 chore(golangci-lint): add forward-looking static checking rules BE-12183 (#1200) 2025-09-12 16:54:30 -03:00
LP B
ad1c015f01 fix(api/custom-templates): UAC-allowed users cannot fetch custom template details (#1113) 2025-09-11 16:08:52 +02:00
LP B
326fdcf6ea refactor(api): remove duplicates of TxResponse + HandlerError detection (#1117) 2025-09-11 11:33:30 +02:00
Malcolm Lockyer
26a0c4e809 fix(encryption): set correct default secret key path [r8s-555] (#1182)
Co-authored-by: Gorbasch <57012534+mbegerau@users.noreply.github.com>
2025-09-11 16:32:43 +12:00
Ali
acb465ae33 fix(node): revert table css selector, add new specific selector [r8s-331] (#1170) 2025-09-11 10:53:35 +12:00
andres-portainer
5418a0bee6 fix(mingit): remove mingit BE-12245 (#1177) 2025-09-10 15:01:12 -03:00
andres-portainer
a59815264d fix(csp): add google.com to the CSP header BE-12228 (#1175) 2025-09-10 15:00:25 -03:00
Viktor Pettersson
3ac0be4e35 chore(gomod): add go mod tidy checks in the CI BE-12233 (#1151) 2025-09-10 08:28:58 +12:00
Ali
feae930293 fix(node): allow switching tabs [r8s-546] (#1161) 2025-09-10 08:17:40 +12:00
LP B
7ebb52ec6d fix(api/container): standard users cannot connect or disconnect containers to networks (#1118) 2025-09-09 22:07:19 +02:00
Ali
8b73ad3b6f chore(kubernetes): node view react migration [r8s-331] (#746) 2025-09-08 22:51:32 +12:00
Ali
6fc2a8234d fix(registry): allow trusted tls custom registries [r8s-489] (#1116) 2025-09-08 09:28:40 +12:00
Ali
e2c2724e36 fix(helm): update helm repo validation to match helm cli [r8s-531] (#1141) 2025-09-08 08:58:04 +12:00
Malcolm Lockyer
6abfbe8553 fix(fips): encrypt the chisel private key file for fips [be-12132] (#1143) 2025-09-05 13:17:30 +12:00
andres-portainer
54f6add45d fix(compose): fix a data race in a test BE-12231 (#1148) 2025-09-04 17:31:57 -03:00
andres-portainer
f8ae5368bf fix(git): add a minimum interval validation BE-12220 (#1144) 2025-09-04 15:11:12 -03:00
andres-portainer
2ba348551d fix(scheduler): fix a data race in the job scheduler BE-12229 (#1146) 2025-09-04 15:09:52 -03:00
andres-portainer
110f88f22d chore(endpointutils): remove unnecessary field BE-10415 (#1136) 2025-09-04 11:22:46 -03:00
James Player
c90a15dd0f refactor(app/repository): migrate edit repository view to React [R8S-332] (#768) 2025-09-04 16:27:39 +12:00
andres-portainer
f4335e1e72 fix(registries): clear sensitive fields in the update handler BE-12215 (#1128) 2025-09-02 15:44:09 -03:00
andres-portainer
8d9e1a0ad5 fix(csp): add object-src to the CSP header BE-12217 (#1126) 2025-09-02 11:39:46 -03:00
andres-portainer
48dcfcb08f fix(forbidigo): add more rules to avoid skipping TLS verifications BE-11973 (#1123) 2025-09-01 16:57:22 -03:00
andres-portainer
def19be230 fix(depguard): mitigate improper usage of openpgp BE-11977 (#1122) 2025-09-01 14:44:45 -03:00
andres-portainer
36154e9d33 fix(depguard): add a rule against golang.org/x/crypto BE-11978 (#1119) 2025-09-01 10:54:24 -03:00
Oscar Zhou
7cf6bb78d6 fix(container): inaccurate healthy container count [BE-2290] (#1114) 2025-09-01 17:01:13 +12:00
Cara Ryan
541f281b29 fix(kubernetes): Namespace resource limits and requests display consistent value (#1055) 2025-09-01 10:25:53 +12:00
Viktor Pettersson
965ef5246b feat(autopatch): implement OCI registry patch finder BE-12111 (#1044) 2025-08-27 19:04:41 +12:00
James Carppe
9c88057bd1 Updates for release 2.33.1 (#1109) 2025-08-27 16:56:01 +12:00
andres-portainer
8c52e92705 chore(bbolt): upgrade bbolt to v1.4.3 BE-12193 (#1103) 2025-08-25 15:51:56 -03:00
Devon Steenberg
3a727d24ce fix(sslflags): Deprecate ssl flags [BE-12168] (#1075) 2025-08-25 14:35:55 +12:00
Malcolm Lockyer
185558a642 fix(standard): manual endpoint refresh fails to save new status [be-12188] (#1092) 2025-08-25 13:49:17 +12:00
Ali
35aa525bd2 fix(environments): create k8s specific edge agent before connecting [r8s-438] (#1088)
Merging because this change is unrelated to the failing kubernetes/tests/helm-oci.spec.ts tests
2025-08-25 09:32:10 +12:00
Oscar Zhou
2ce8788487 fix(autoupdate): update tooltips in edge stack gitops update [BE-12177] (#1084) 2025-08-23 10:56:04 +12:00
andres-portainer
ec0e98a64b chore(linters): enable testifylint BE-12183 (#1091) 2025-08-22 15:31:10 -03:00
Steven Kang
121e9f03a4 fix: GHSA-2464-8j7c-4cjm - develop [R8S-495] (#1087) 2025-08-22 14:03:13 +12:00
andres-portainer
a0295b1a39 chore(go): upgrade Go to v1.25.0 BE-12181 (#1071) 2025-08-20 12:55:06 -03:00
andres-portainer
30aba86380 chore(benchmarks): use b.Loop() BE-12182 (#1072) 2025-08-20 12:54:26 -03:00
James Carppe
89f5a20786 Updates for release 2.33.0 (#1067) 2025-08-20 15:35:58 +12:00
James Player
ef7caa260b fix(UI): add experimental features back in [r8s-483] (#1061) 2025-08-19 16:55:24 +12:00
Steven Kang
39d50ef70e fix: cve-2025-55198 and cve-2025-55199 - develop [R8S-482] (#1057) 2025-08-19 16:22:52 +12:00
James Player
58a1392480 fix(helm): support http and custom tls helm registries, give help when misconfigured - develop [r8s-472] (#1050)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-08-19 13:32:32 +12:00
James Player
06f6bcc340 fix(ui): Fixed react-select TooManyResultsSelector filter and improved scrolling (#1024) 2025-08-19 09:35:00 +12:00
LP B
c9d18b614b fix(api/edge-stacks): avoid overriding updates with old values (#1047) 2025-08-16 03:52:13 +02:00
andres-portainer
2035c42c3c fix(migrator): rewrite a migration so it is idempotent BE-12053 (#1042) 2025-08-15 09:26:10 -03:00
Malcolm Lockyer
a760426b87 fix(fips): use standard lib pbkdf2 [be-12164] (#1038) 2025-08-15 11:44:35 +12:00
andres-portainer
10b129a02e fix(crypto): replace fips140 calls with fips calls BE-11979 (#1033) 2025-08-14 19:36:15 -03:00
Cara Ryan
129b9d5db9 fix(pending-actions): Small improvements to pending actions (R8S-350) (#949) 2025-08-15 10:07:51 +12:00
andres-portainer
2c08becf6c feat(openai): remove OpenAI BE-12018 (#873) 2025-08-14 10:42:21 -03:00
Ali
a3bfe7cb0c fix(logs): improve log rendering performance [r8s-437] (#993) 2025-08-14 13:55:37 +12:00
andres-portainer
7049a8a2bb fix(linters): add many linters BE-12112 (#1009) 2025-08-13 19:42:24 -03:00
LP B
1197b1dd8d feat(api): Permissions-Policy header deny all (#1021) 2025-08-13 22:07:55 +02:00
andres-portainer
7f167ff2fc fix(auth): remove a nil pointer dereference BE-12149 (#1014) 2025-08-13 13:20:56 -03:00
1436 changed files with 75714 additions and 33285 deletions

View File

@@ -17,7 +17,7 @@ plugins:
- import
parserOptions:
ecmaVersion: 2018
ecmaVersion: latest
sourceType: module
project: './tsconfig.json'
ecmaFeatures:
@@ -114,7 +114,13 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
'jsx-a11y/label-has-associated-control':
- error
- assert: either
controlComponents:
- Input
- Checkbox
'jsx-a11y/control-has-associated-label': off
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
'react/jsx-no-bind': off
'no-await-in-loop': 'off'
@@ -133,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:
@@ -149,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

View File

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

View File

@@ -22,7 +22,7 @@ body:
options:
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
required: true
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io).
required: true
- type: markdown
@@ -94,6 +94,21 @@ 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.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'
- '2.33.3'
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
- '2.31.3'
- '2.31.2'
@@ -128,8 +143,6 @@ body:
- '2.21.4'
- '2.21.3'
- '2.21.2'
- '2.21.1'
- '2.21.0'
validations:
required: true

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ api/docs
.env
go.work.sum
.vitest

16
.golangci-forward.yaml Normal file
View File

@@ -0,0 +1,16 @@
version: "2"
linters:
default: none
enable:
- forbidigo
settings:
forbidigo:
forbid:
- 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:
rules:
- path: _test\.go
linters:
- forbidigo

View File

@@ -1,10 +1,14 @@
version: "2"
run:
allow-parallel-runners: true
linters:
default: none
enable:
- bodyclose
- copyloopvar
- depguard
- errcheck
- errorlint
- forbidigo
- govet
@@ -13,6 +17,18 @@ linters:
- perfsprint
- staticcheck
- unused
- mirror
- durationcheck
- errorlint
- govet
- usetesting
- zerologlint
- testifylint
- modernize
- unconvert
- unused
- zerologlint
- exptostd
settings:
staticcheck:
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
@@ -32,12 +48,44 @@ linters:
desc: use github.com/portainer/portainer/pkg/libcrypto
- pkg: github.com/portainer/libhttp
desc: use github.com/portainer/portainer/pkg/libhttp
- pkg: golang.org/x/crypto
desc: golang.org/x/crypto is not allowed because of FIPS mode
- pkg: github.com/ProtonMail/go-crypto/openpgp
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
- pkg: github.com/cosi-project/runtime
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
- pkg: gopkg.in/yaml.v2
desc: use go.yaml.in/yaml/v3 instead
- pkg: gopkg.in/yaml.v3
desc: use go.yaml.in/yaml/v3 instead
- pkg: github.com/golang-jwt/jwt/v4
desc: use github.com/golang-jwt/jwt/v5 instead
- pkg: github.com/mitchellh/mapstructure
desc: use github.com/go-viper/mapstructure/v2 instead
- pkg: gopkg.in/alecthomas/kingpin.v2
desc: use github.com/alecthomas/kingpin/v2 instead
- pkg: github.com/jcmturner/gokrb5$
desc: use github.com/jcmturner/gokrb5/v8 instead
- pkg: github.com/gofrs/uuid
desc: use github.com/google/uuid
- pkg: github.com/Masterminds/semver$
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/blang/semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/coreos/go-semver
desc: use github.com/Masterminds/semver/v3
- pkg: github.com/hashicorp/go-version
desc: use github.com/Masterminds/semver/v3
forbidigo:
forbid:
- pattern: ^tls\.Config$
msg: Use crypto.CreateTLSConfiguration() instead
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
- pattern: ^object\.(Commit|Tag)\.Verify$
msg: "Not allowed because of FIPS mode"
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
msg: "Not allowed because of FIPS mode"
analyze-types: true
exclusions:
generated: lax
@@ -45,12 +93,13 @@ linters:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
exclusions:
generated: lax
paths:

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd $(dirname -- "$0") && yarn lint-staged
cd $(dirname -- "$0") && pnpm lint-staged

View File

@@ -1,2 +1,3 @@
dist
api/datastore/test_data
api/datastore/test_data
coverage

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,
},
],
},
},
],

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
CLAUDE.md Normal file
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.25.7 (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)

View File

@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
## Build and run Portainer locally
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
Install dependencies:

View File

@@ -1,9 +1,3 @@
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
# For a list of valid GOOS and GOARCH values
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
PLATFORM=$(shell go env GOOS)
ARCH=$(shell go env GOARCH)
# build target, can be one of "production", "testing", "development"
ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
@@ -26,7 +20,7 @@ all: tidy deps build-server build-client ## Build the client, server and downloa
build-all: all ## Alias for the 'all' target (used by CI)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
build-server: init-dist ## Build the server binary
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
@@ -35,11 +29,7 @@ build-image: build-all ## Build the Portainer image locally
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
build-storybook: ## Build and serve the storybook files
yarn storybook:build
devops: clean deps build-client ## Build the everything target specifically for CI
echo "Building the devops binary..."
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
pnpm run storybook:build
##@ Build dependencies
.PHONY: deps server-deps client-deps tidy
@@ -49,25 +39,23 @@ server-deps: init-dist ## Download dependant server binaries
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
yarn
pnpm install
tidy: ## Tidy up the go.mod file
@go mod tidy
##@ Cleanup
.PHONY: clean
clean: ## Remove all build and download artifacts
@echo "Clearing the dist directory..."
@rm -rf dist/*
##@ Testing
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-client: ## Run client tests
yarn test $(ARGS) --coverage
pnpm run test $(ARGS) --coverage
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
@@ -79,7 +67,7 @@ dev: ## Run both the client and server in development mode
make dev-client
dev-client: ## Run the client in development mode
yarn dev
pnpm install && pnpm run dev
dev-server: build-server ## Run the server in development mode
@./dev/run_container.sh
@@ -93,7 +81,7 @@ dev-server-podman: build-server ## Run the server in development mode
format: format-client format-server ## Format all code
format-client: ## Format client code
yarn format
pnpm run format
format-server: ## Format server code
go fmt ./...
@@ -103,26 +91,26 @@ format-server: ## Format server code
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
yarn lint
pnpm run lint
lint-server: ## Lint server code
lint-server: tidy ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
##@ Extension
.PHONY: dev-extension
dev-extension: build-server build-client ## Run the extension in development mode
make local -f build/docker-extension/Makefile
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
go mod download -x
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
yarn swagger-cli validate dist/docs/openapi.yaml
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
pnpm swagger-cli validate dist/docs/openapi.yaml
##@ Helpers
.PHONY: help

View File

@@ -46,7 +46,7 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
## Security
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
## Work for us

61
SECURITY.md Normal file
View File

@@ -0,0 +1,61 @@
# Security Policy
## Supported Versions
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
| Version Type | Support Status |
| --- | --- |
| LTS (Long-Term Support) | Supported for critical security fixes |
| STS (Short-Term Support) | Supported until the next STS or LTS release |
| Legacy / EOL | Not supported |
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
## Reporting a Vulnerability
The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly.
**Please do not report security vulnerabilities via public GitHub issues.**
### Disclosure Process
1. **Report**: You can report in one of two ways:
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
- **Email**: Send your findings to security@portainer.io.
2. **Details**: To help us verify the issue, please include:
- A description of the vulnerability and its potential impact.
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
- The version of the software and the environment in which it was found.
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users.
## Our Commitment
If you follow the responsible disclosure process, we will:
- Respond to your report in a timely manner.
- Provide an estimated timeline for remediation.
- Notify you when the vulnerability has been patched.
- Give credit for the discovery (if desired) once the fix is public.
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
Thank you for helping keep Portainer and our community secure.
## Resources
- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase)

View File

@@ -11,20 +11,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/url"
"github.com/rs/zerolog/log"
)
// GetAgentVersionAndPlatform returns the agent version and platform
//
// it sends a ping to the agent and parses the version and platform from the headers
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
httpCli := &http.Client{
Timeout: 3 * time.Second,
}
httpCli := &http.Client{Timeout: 3 * time.Second}
if tlsConfig != nil {
httpCli.Transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
}
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
@@ -44,8 +42,10 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
return 0, "", err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
if resp.StatusCode != http.StatusNoContent {
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)

View File

@@ -10,31 +10,31 @@ func Test_generateRandomKey(t *testing.T) {
is := assert.New(t)
tests := []struct {
name string
wantLenth int
name string
wantLength int
}{
{
name: "Generate a random key of length 16",
wantLenth: 16,
name: "Generate a random key of length 16",
wantLength: 16,
},
{
name: "Generate a random key of length 32",
wantLenth: 32,
name: "Generate a random key of length 32",
wantLength: 32,
},
{
name: "Generate a random key of length 64",
wantLenth: 64,
name: "Generate a random key of length 64",
wantLength: 64,
},
{
name: "Generate a random key of length 128",
wantLenth: 128,
name: "Generate a random key of length 128",
wantLength: 128,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GenerateRandomKey(tt.wantLenth)
is.Equal(tt.wantLenth, len(got))
got := GenerateRandomKey(tt.wantLength)
is.Len(got, tt.wantLength)
})
}

View File

@@ -10,9 +10,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/assert"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
@@ -30,7 +31,7 @@ func Test_GenerateApiKey(t *testing.T) {
t.Run("Successfully generates API key", func(t *testing.T) {
desc := "test-1"
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
is.NoError(err)
require.NoError(t, err)
is.NotEmpty(rawKey)
is.NotEmpty(apiKey)
is.Equal(desc, apiKey.Description)
@@ -38,7 +39,7 @@ func Test_GenerateApiKey(t *testing.T) {
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
is.NoError(err)
require.NoError(t, err)
is.Equal(rawKey[:7], apiKey.Prefix)
is.Len(apiKey.Prefix, 7)
@@ -46,7 +47,7 @@ func Test_GenerateApiKey(t *testing.T) {
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
is.NoError(err)
require.NoError(t, err)
is.Equal(portainerAPIKeyPrefix, "ptr_")
is.True(strings.HasPrefix(rawKey, "ptr_"))
@@ -55,7 +56,7 @@ func Test_GenerateApiKey(t *testing.T) {
t.Run("Successfully caches API key", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-3")
is.NoError(err)
require.NoError(t, err)
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
is.True(ok)
@@ -65,7 +66,7 @@ func Test_GenerateApiKey(t *testing.T) {
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
is.NoError(err)
require.NoError(t, err)
generatedDigest := sha256.Sum256([]byte(rawKey))
@@ -83,10 +84,10 @@ func Test_GetAPIKey(t *testing.T) {
t.Run("Successfully returns all API keys", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
is.NoError(err)
require.NoError(t, err)
is.Equal(apiKey, apiKeyGot)
})
@@ -102,12 +103,12 @@ func Test_GetAPIKeys(t *testing.T) {
t.Run("Successfully returns all API keys", func(t *testing.T) {
user := portainer.User{ID: 1}
_, _, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
_, _, err = service.GenerateApiKey(user, "test-2")
is.NoError(err)
require.NoError(t, err)
keys, err := service.GetAPIKeys(user.ID)
is.NoError(err)
require.NoError(t, err)
is.Len(keys, 2)
})
}
@@ -122,10 +123,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
is.NoError(err)
require.NoError(t, err)
is.Equal(user, userGot)
is.Equal(*apiKey, apiKeyGot)
})
@@ -133,10 +134,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
is.NoError(err)
require.NoError(t, err)
is.Equal(user, userGot)
is.Equal(*apiKey, apiKeyGot)
@@ -156,16 +157,19 @@ func Test_UpdateAPIKey(t *testing.T) {
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
user := portainer.User{ID: 1}
store.User().Create(&user)
err := store.User().Create(&user)
require.NoError(t, err)
_, apiKey, err := service.GenerateApiKey(user, "test-x")
is.NoError(err)
require.NoError(t, err)
apiKey.LastUsed = time.Now().UTC().Unix()
err = service.UpdateAPIKey(apiKey)
is.NoError(err)
require.NoError(t, err)
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
is.NoError(err)
require.NoError(t, err)
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
@@ -174,7 +178,7 @@ func Test_UpdateAPIKey(t *testing.T) {
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
is.NoError(err)
require.NoError(t, err)
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
is.True(ok)
@@ -184,7 +188,7 @@ func Test_UpdateAPIKey(t *testing.T) {
is.NotEqual(*apiKey, apiKeyFromCache)
err = service.UpdateAPIKey(apiKey)
is.NoError(err)
require.NoError(t, err)
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
is.True(ok)
@@ -202,30 +206,30 @@ func Test_DeleteAPIKey(t *testing.T) {
t.Run("Successfully updates the api-key", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
is.NoError(err)
require.NoError(t, err)
is.Equal(*apiKey, apiKeyGot)
err = service.DeleteAPIKey(apiKey.ID)
is.NoError(err)
require.NoError(t, err)
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
is.Error(err)
require.Error(t, err)
})
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
user := portainer.User{ID: 1}
_, apiKey, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
is.True(ok)
is.Equal(*apiKey, apiKeyFromCache)
err = service.DeleteAPIKey(apiKey.ID)
is.NoError(err)
require.NoError(t, err)
_, _, ok = service.cache.Get(apiKey.Digest)
is.False(ok)
@@ -243,10 +247,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
// generate api keys
user := portainer.User{ID: 1}
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
is.NoError(err)
require.NoError(t, err)
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
is.NoError(err)
require.NoError(t, err)
// verify api keys are present in cache
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
@@ -273,11 +277,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
// generate keys for 2 users
user1 := portainer.User{ID: 1}
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
is.NoError(err)
require.NoError(t, err)
user2 := portainer.User{ID: 2}
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
is.NoError(err)
require.NoError(t, err)
// verify keys in cache
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)

View File

@@ -17,18 +17,15 @@ func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, e
Size: int64(len(fileContent)),
}
err := tarWriter.WriteHeader(header)
if err != nil {
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
_, err = tarWriter.Write(fileContent)
if err != nil {
if _, err := tarWriter.Write(fileContent); err != nil {
return nil, err
}
err = tarWriter.Close()
if err != nil {
if err := tarWriter.Close(); err != nil {
return nil, err
}
@@ -43,10 +40,7 @@ type tarFileInBuffer struct {
func NewTarFileInBuffer() *tarFileInBuffer {
var b bytes.Buffer
return &tarFileInBuffer{
b: &b,
w: tar.NewWriter(&b),
}
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
}
// Put puts a single file to tar archive buffer.
@@ -61,11 +55,9 @@ func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) e
return err
}
if _, err := t.w.Write(fileContent); err != nil {
return err
}
_, err := t.w.Write(fileContent)
return nil
return err
}
// Bytes returns the archive as a byte array.

View File

@@ -9,6 +9,9 @@ import (
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
)
// TarGzDir creates a tar.gz archive and returns it's path.
@@ -20,12 +23,13 @@ func TarGzDir(absolutePath string) (string, error) {
if err != nil {
return "", err
}
defer outFile.Close()
defer logs.CloseAndLogErr(outFile)
zipWriter := gzip.NewWriter(outFile)
defer zipWriter.Close()
defer logs.CloseAndLogErr(zipWriter)
tarWriter := tar.NewWriter(zipWriter)
defer tarWriter.Close()
defer logs.CloseAndLogErr(tarWriter)
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
@@ -86,7 +90,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
if err != nil {
return err
}
defer zipReader.Close()
defer logs.CloseAndLogErr(zipReader)
tarReader := tar.NewReader(zipReader)
@@ -105,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
p := filesystem.JoinPaths(outputDirPath, header.Name)
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
@@ -116,7 +120,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
if _, err := io.Copy(outFile, tarReader); err != nil {
return fmt.Errorf("Failed to extract file %s", header.Name)
}
outFile.Close()
logs.CloseAndLogErr(outFile)
default:
return fmt.Errorf("tar: unknown type: %v in %s",
header.Typeflag,

View File

@@ -1,24 +1,34 @@
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"
)
func listFiles(dir string) []string {
items := make([]string, 0)
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
items = append(items, path)
return nil
})
}); err != nil {
log.Warn().Err(err).Msg("failed to list files in directory")
}
return items
}
@@ -26,13 +36,21 @@ func listFiles(dir string) []string {
func Test_shouldCreateArchive(t *testing.T) {
tmpdir := t.TempDir()
content := []byte("content")
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
require.NoError(t, err)
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
require.NoError(t, err)
gzPath, err := TarGzDir(tmpdir)
assert.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
extractionDir := t.TempDir()
@@ -45,7 +63,8 @@ func Test_shouldCreateArchive(t *testing.T) {
wasExtracted := func(p string) {
fullpath := path.Join(extractionDir, p)
assert.Contains(t, extractedFiles, fullpath)
copyContent, _ := os.ReadFile(fullpath)
copyContent, err := os.ReadFile(fullpath)
require.NoError(t, err)
assert.Equal(t, content, copyContent)
}
@@ -57,13 +76,21 @@ func Test_shouldCreateArchive(t *testing.T) {
func Test_shouldCreateArchive2(t *testing.T) {
tmpdir := t.TempDir()
content := []byte("content")
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
require.NoError(t, err)
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
require.NoError(t, err)
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
require.NoError(t, err)
gzPath, err := TarGzDir(tmpdir)
assert.Nil(t, err)
require.NoError(t, err)
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
extractionDir := t.TempDir()
@@ -84,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)
}

View File

@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"github.com/portainer/portainer/api/logs"
"github.com/pkg/errors"
)
@@ -18,7 +20,7 @@ func UnzipFile(src string, dest string) error {
if err != nil {
return err
}
defer r.Close()
defer logs.CloseAndLogErr(r)
for _, f := range r.File {
p := filepath.Join(dest, f.Name)
@@ -30,7 +32,9 @@ func UnzipFile(src string, dest string) error {
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(p, os.ModePerm)
if err := os.MkdirAll(p, os.ModePerm); err != nil {
return err
}
continue
}
@@ -53,13 +57,13 @@ func unzipFile(f *zip.File, p string) error {
if err != nil {
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
}
defer outFile.Close()
defer logs.CloseAndLogErr(outFile)
rc, err := f.Open()
if err != nil {
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
}
defer rc.Close()
defer logs.CloseAndLogErr(rc)
if _, err = io.Copy(outFile, rc); err != nil {
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUnzipFile(t *testing.T) {
@@ -20,7 +21,7 @@ func TestUnzipFile(t *testing.T) {
err := UnzipFile("./testdata/sample_archive.zip", dir)
assert.NoError(t, err)
require.NoError(t, err)
archiveDir := dir + "/sample_archive"
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))

View File

@@ -6,6 +6,15 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ecr"
)
// Registry represents an ECR registry endpoint information.
// This struct is used to parse and validate ECR endpoint URLs.
type Registry struct {
ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com")
FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL)
Region string // AWS region (e.g., "us-east-1", "us-gov-west-1")
Public bool // Whether this is ecr-public.aws.com
}
type (
Service struct {
accessKey string

View File

@@ -0,0 +1,70 @@
package ecr
import (
"fmt"
"net/url"
"regexp"
"strings"
)
// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats.
// Based on AWS ECR credential helper regex but extended to support accountless endpoints.
//
// Supported formats:
// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com
// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws
// - Accountless service: ecr-fips.us-west-1.amazonaws.com
// - Accountless API: ecr-fips.us-east-1.api.aws
// - Non-FIPS variants: All formats above without "-fips"
//
// Regex groups:
// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-"
// - Group 2: Account ID (optional) - e.g., "123456789012"
// - Group 3: FIPS flag (optional) - either "-fips" or empty string
// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1"
// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws"
var ecrEndpointPattern = regexp.MustCompile(
`^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`,
)
// ParseECREndpoint parses an ECR registry URL and extracts registry information.
// This function replaces the AWS ECR credential helper library's ExtractRegistry function,
// which only supports account-prefixed endpoints.
//
// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html
func ParseECREndpoint(urlStr string) (*Registry, error) {
// Normalize URL by adding https:// prefix if not present
if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") {
urlStr = "https://" + urlStr
}
u, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
hostname := u.Hostname()
// Special case: ECR Public
// ECR Public uses a different domain and doesn't have FIPS variant
if hostname == "ecr-public.aws.com" {
return &Registry{
FIPS: false,
Public: true,
}, nil
}
// Parse standard ECR endpoints using regex
matches := ecrEndpointPattern.FindStringSubmatch(hostname)
if len(matches) == 0 {
return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname)
}
return &Registry{
ID: matches[2], // Account ID (may be empty for accountless endpoints)
FIPS: matches[3] == "-fips", // Check if "-fips" is present
Region: matches[4], // AWS region
Public: false,
}, nil
}

View File

@@ -0,0 +1,253 @@
package ecr
import (
"testing"
)
func TestParseECREndpoint(t *testing.T) {
tests := []struct {
name string
url string
want *Registry
wantError bool
}{
// Standard AWS Commercial - Account-prefixed FIPS
{
name: "account-prefixed FIPS us-east-1",
url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-east-1",
Public: false,
},
},
{
name: "account-prefixed FIPS us-west-2",
url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-west-2",
Public: false,
},
},
// Accountless FIPS service endpoints
{
name: "accountless FIPS us-west-1",
url: "ecr-fips.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless FIPS us-east-2",
url: "ecr-fips.us-east-2.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-east-2",
Public: false,
},
},
// Accountless FIPS API endpoints
{
name: "accountless FIPS API us-west-1",
url: "ecr-fips.us-west-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless FIPS API us-east-1",
url: "ecr-fips.us-east-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-east-1",
Public: false,
},
},
// on.aws domain with hyphen separator
{
name: "account-prefixed FIPS hyphen us-west-1",
url: "123456789012.dkr-ecr-fips.us-west-1.on.aws",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
{
name: "account-prefixed FIPS hyphen us-east-2",
url: "123456789012.dkr-ecr-fips.us-east-2.on.aws",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-east-2",
Public: false,
},
},
// AWS GovCloud
{
name: "account-prefixed FIPS us-gov-east-1",
url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-gov-east-1",
Public: false,
},
},
{
name: "account-prefixed FIPS us-gov-west-1",
url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: true,
Region: "us-gov-west-1",
Public: false,
},
},
{
name: "accountless FIPS us-gov-west-1",
url: "ecr-fips.us-gov-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-gov-west-1",
Public: false,
},
},
{
name: "accountless FIPS API us-gov-east-1",
url: "ecr-fips.us-gov-east-1.api.aws",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-gov-east-1",
Public: false,
},
},
// ECR Public
{
name: "ecr-public",
url: "ecr-public.aws.com",
want: &Registry{
ID: "",
FIPS: false,
Region: "",
Public: true,
},
},
// Non-FIPS endpoints (valid ECR but FIPS=false)
{
name: "account-prefixed non-FIPS us-east-1",
url: "123456789012.dkr.ecr.us-east-1.amazonaws.com",
want: &Registry{
ID: "123456789012",
FIPS: false,
Region: "us-east-1",
Public: false,
},
},
{
name: "accountless non-FIPS us-west-1",
url: "ecr.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: false,
Region: "us-west-1",
Public: false,
},
},
{
name: "accountless non-FIPS API us-east-2",
url: "ecr.us-east-2.api.aws",
want: &Registry{
ID: "",
FIPS: false,
Region: "us-east-2",
Public: false,
},
},
// URLs with https:// prefix
{
name: "with https prefix",
url: "https://ecr-fips.us-west-1.amazonaws.com",
want: &Registry{
ID: "",
FIPS: true,
Region: "us-west-1",
Public: false,
},
},
// Invalid endpoints
{
name: "not an ECR URL",
url: "not-an-ecr-url.com",
wantError: true,
},
{
name: "invalid account ID length",
url: "123.dkr.ecr-fips.us-east-1.amazonaws.com",
wantError: true,
},
{
name: "empty string",
url: "",
wantError: true,
},
{
name: "docker hub",
url: "docker.io",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseECREndpoint(tt.url)
if tt.wantError {
if err == nil {
t.Errorf("ParseECREndpoint() expected error but got none")
}
return
}
if err != nil {
t.Errorf("ParseECREndpoint() unexpected error: %v", err)
return
}
if got.ID != tt.want.ID {
t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID)
}
if got.FIPS != tt.want.FIPS {
t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS)
}
if got.Region != tt.want.Region {
t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region)
}
if got.Public != tt.want.Public {
t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/logs"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
@@ -97,7 +98,7 @@ func encrypt(path string, passphrase string) (string, error) {
if err != nil {
return "", err
}
defer in.Close()
defer logs.CloseAndLogErr(in)
outFileName := path + ".encrypted"
out, err := os.Create(outFileName)
@@ -105,7 +106,5 @@ func encrypt(path string, passphrase string) (string, error) {
return "", err
}
err = crypto.AesEncrypt(in, out, []byte(passphrase))
return outFileName, err
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
}

View File

@@ -16,6 +16,8 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/rs/zerolog/log"
)
var filesToRestore = append(filesToBackup, "portainer.db")
@@ -31,17 +33,20 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
}
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
defer os.RemoveAll(filepath.Dir(restorePath))
defer func() {
if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil {
log.Warn().Err(err).Msg("failed to clean up restore files")
}
}()
err = extractArchive(archive, restorePath)
if err != nil {
if err := extractArchive(archive, restorePath); err != nil {
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
}
unlock := gate.Lock()
defer unlock()
if err = datastore.Close(); err != nil {
if err := datastore.Close(); err != nil {
return errors.Wrap(err, "Failed to stop db")
}
@@ -51,7 +56,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
}
if err = restoreFiles(restorePath, filestorePath); err != nil {
if err := restoreFiles(restorePath, filestorePath); err != nil {
return errors.Wrap(err, "failed to restore the system state")
}
@@ -89,8 +94,7 @@ func getRestoreSourcePath(dir string) (string, error) {
func restoreFiles(srcDir string, destinationDir string) error {
for _, filename := range filesToRestore {
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
if err != nil {
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
return err
}
}
@@ -98,14 +102,18 @@ func restoreFiles(srcDir string, destinationDir string) error {
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
// Prevent the possibility of having both databases. Remove any default new instance
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) {
return err
}
// Now copy the database. It'll be either portainer.db or portainer.edb
// Note: CopyPath does not return an error if the source file doesn't exist
err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir)
if err != nil {
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
return err
}

View File

@@ -89,10 +89,8 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
_, _ = io.Copy(io.Discard, resp.Body)
return resp.Body.Close()
}
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done

View File

@@ -142,7 +142,9 @@ func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
continue
}
conn.Close()
if err := conn.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close tcp connection")
}
break
}

View File

@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
@@ -52,7 +52,6 @@ func CLIFlags() *portainer.CLIFlags {
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
@@ -95,6 +94,11 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
kingpin.Parse()
if !filepath.IsAbs(*flags.Assets) {

View File

@@ -1,5 +1,4 @@
//go:build !windows
// +build !windows
package cli

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")
}
@@ -134,15 +134,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
InstanceID: instanceId.String(),
MigratorCount: migratorCount,
}
store.VersionService.UpdateVersion(&v)
if err := store.VersionService.UpdateVersion(&v); err != nil {
log.Fatal().Err(err).Msg("failed to update version")
}
if err := updateSettingsFromFlags(store, flags); err != nil {
log.Fatal().Err(err).Msg("failed updating settings from flags")
}
} else {
if err := store.MigrateData(); err != nil {
log.Fatal().Err(err).Msg("failed migration")
}
} else if err := store.MigrateData(); err != nil {
log.Fatal().Err(err).Msg("failed migration")
}
if err := updateSettingsFromFlags(store, flags); err != nil {
@@ -153,7 +154,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
go func() {
<-shutdownCtx.Done()
defer connection.Close()
defer logs.CloseAndLogErr(connection)
}()
return store
@@ -347,7 +348,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
trustedOrigins := []string{}
if *flags.TrustedOrigins != "" {
// validate if the trusted origins are valid urls
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
if !validate.IsTrustedOrigin(origin) {
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
}
@@ -529,7 +530,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
scheduler := scheduler.NewScheduler(shutdownCtx)
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil {
log.Fatal().Err(err).Msg("failed to start stack scheduler")
}
sslDBSettings, err := dataStore.SSLSettings().Settings()
if err != nil {
@@ -630,7 +633,7 @@ func main() {
Str("build_number", build.BuildNumber).
Str("image_tag", build.ImageTag).
Str("nodejs_version", build.NodejsVersion).
Str("yarn_version", build.YarnVersion).
Str("pnpm_version", build.PnpmVersion).
Str("webpack_version", build.WebpackVersion).
Str("go_version", build.GoVersion).
Msg("starting Portainer")

View File

@@ -15,8 +15,9 @@ import (
"github.com/portainer/portainer/pkg/fips"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/scrypt"
// Not allowed in FIPS mode
"golang.org/x/crypto/argon2" //nolint:depguard
"golang.org/x/crypto/scrypt" //nolint:depguard
)
const (
@@ -163,7 +164,9 @@ func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
return err
}
nonce.Increment()
if err := nonce.Increment(); err != nil {
return err
}
}
return nil
@@ -234,7 +237,9 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
nonce.Increment()
if err := nonce.Increment(); err != nil {
return nil, err
}
}
return &buf, nil

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"testing"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
@@ -47,26 +48,29 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
)
content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
require.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
require.NoError(t, err, "Failed to encrypt a file")
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
require.Nil(t, err, "Couldn't read encrypted file")
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
if !decryptShouldSucceed {
@@ -74,9 +78,11 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
} else {
require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
}
@@ -147,33 +153,40 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
require.NoError(t, err, "Failed to encrypt a file")
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
require.NoError(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
@@ -197,33 +210,40 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
require.NoError(t, err, "Failed to encrypt a file")
logs.CloseAndLogErr(encryptedFileWriter)
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
assert.Nil(t, err, "Failed to decrypt file")
require.NoError(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
@@ -247,32 +267,40 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
)
content := randBytes(1024 * 50)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileWriter)
err = encrypt(originFile, encryptedFileWriter, []byte(""))
require.NoError(t, err, "Failed to encrypt a file")
err := encrypt(originFile, encryptedFileWriter, []byte(""))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
assert.Nil(t, err, "Failed to decrypt file")
require.NoError(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
_, err = io.Copy(decryptedFileWriter, decryptedReader)
require.NoError(t, err)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
decryptedContent, err := os.ReadFile(decryptedFilePath)
require.NoError(t, err)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
@@ -296,28 +324,33 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
)
content := randBytes(1034)
os.WriteFile(originFilePath, content, 0600)
err := os.WriteFile(originFilePath, content, 0600)
require.NoError(t, err)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
originFile, err := os.Open(originFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(originFile)
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
encryptedFileWriter, err := os.Create(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileWriter)
err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file")
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
require.NoError(t, err, "Failed to encrypt a file")
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
require.NoError(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
encryptedFileReader, err := os.Open(encryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(encryptedFileReader)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedFileWriter, err := os.Create(decryptedFilePath)
require.NoError(t, err)
defer logs.CloseAndLogErr(decryptedFileWriter)
_, err = decrypt(encryptedFileReader, []byte("garbage"))
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
}
t.Run("fips", func(t *testing.T) {

View File

@@ -11,12 +11,12 @@ func TestCreateSignature(t *testing.T) {
privKey, pubKey, err := s.GenerateKeyPair()
require.NoError(t, err)
require.Greater(t, len(privKey), 0)
require.Greater(t, len(pubKey), 0)
require.NotEmpty(t, privKey)
require.NotEmpty(t, pubKey)
m := "test message"
r, err := s.CreateSignature(m)
require.NoError(t, err)
require.NotEqual(t, r, m)
require.Greater(t, len(r), 0)
require.NotEmpty(t, r)
}

View File

@@ -1,7 +1,8 @@
package crypto
import (
"golang.org/x/crypto/bcrypt"
// Not allowed in FIPS mode
"golang.org/x/crypto/bcrypt" //nolint:depguard
)
// Service represents a service for encrypting/hashing data.

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
}

View File

@@ -44,7 +44,7 @@ func TestCreateTLSConfigurationFIPS(t *testing.T) {
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
// No TLS
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
require.Nil(t, err)
require.NoError(t, err)
require.Nil(t, config)
// Skip TLS client/server verifications
@@ -61,7 +61,7 @@ func TestCreateTLSConfigurationFromBytes(t *testing.T) {
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
// No TLS
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
require.Nil(t, err)
require.NoError(t, err)
require.Nil(t, config)
// Skip TLS verifications

View File

@@ -136,10 +136,8 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
func (connection *DbConnection) Open() error {
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
// Now we open the db
databasePath := connection.GetDatabaseFilePath()
db, err := bolt.Open(databasePath, 0600, connection.boltOptions())
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
if err != nil {
return err
}
@@ -152,6 +150,15 @@ func (connection *DbConnection) Open() error {
log.Info().Msg("compacting database")
if err := connection.compact(); err != nil {
log.Error().Err(err).Msg("failed to compact database")
// Close the read-only database and re-open in read-write mode
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
}
connection.Compact = false
return connection.Open()
} else {
log.Info().Msg("database compaction completed")
}
@@ -424,9 +431,14 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
}
// compact attempts to compact the database and replace it iff it succeeds
func (connection *DbConnection) compact() error {
func (connection *DbConnection) compact() (err error) {
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions())
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
}
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
if err != nil {
return fmt.Errorf("failure to create the compacted database: %w", err)
}
@@ -453,11 +465,12 @@ func (connection *DbConnection) compact() error {
return nil
}
func (connection *DbConnection) boltOptions() *bolt.Options {
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
return &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
ReadOnly: readOnly,
}
}

View File

@@ -5,6 +5,8 @@ import (
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
@@ -96,18 +98,36 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
// Special case. If portainer.db and portainer.edb exist.
dbFile1 := path.Join(connection.Path, DatabaseFileName)
f, _ := os.Create(dbFile1)
f.Close()
defer os.Remove(dbFile1)
err := f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile1)
require.NoError(t, err)
}()
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
f, _ = os.Create(dbFile2)
f.Close()
defer os.Remove(dbFile2)
err = f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile2)
require.NoError(t, err)
}()
} else if tc.dbname != "" {
dbFile := path.Join(connection.Path, tc.dbname)
f, _ := os.Create(dbFile)
f.Close()
defer os.Remove(dbFile)
err := f.Close()
require.NoError(t, err)
defer func() {
err := os.Remove(dbFile)
require.NoError(t, err)
}()
}
if tc.key {
@@ -123,10 +143,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
func TestDBCompaction(t *testing.T) {
db := &DbConnection{
Path: t.TempDir(),
Compact: true,
}
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
require.NoError(t, err)
@@ -137,7 +154,8 @@ func TestDBCompaction(t *testing.T) {
return err
}
b.Put([]byte("key"), []byte("value"))
err = b.Put([]byte("key"), []byte("value"))
require.NoError(t, err)
return nil
})
@@ -147,6 +165,7 @@ func TestDBCompaction(t *testing.T) {
require.NoError(t, err)
// Reopen the DB to trigger compaction
db.Compact = true
err = db.Open()
require.NoError(t, err)
@@ -168,10 +187,14 @@ func TestDBCompaction(t *testing.T) {
require.NoError(t, err)
// Failures
err = os.Mkdir(db.GetDatabaseFilePath()+compactedSuffix, 0o755)
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
err = os.Mkdir(compactedPath, 0o755)
require.NoError(t, err)
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
require.NoError(t, err)
require.NoError(t, f.Close())
err = db.Open()
require.NoError(t, err)
}

View File

@@ -3,6 +3,7 @@ package boltdb
import (
"time"
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
bolt "go.etcd.io/bbolt"
@@ -37,7 +38,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
if err != nil {
return []byte("{}"), err
}
defer connection.Close()
defer logs.CloseAndLogErr(connection)
backup := make(map[string]any)
if metadata {

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)

View File

@@ -10,14 +10,14 @@ import (
"io"
"testing"
"github.com/gofrs/uuid"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
@@ -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
@@ -94,7 +94,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
for _, test := range tests {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
data, err := conn.MarshalObject(test.object)
is.NoError(err)
require.NoError(t, err)
is.Equal(test.expected, string(data))
})
}
@@ -135,7 +135,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
var object string
err := conn.UnmarshalObject(test.object, &object)
is.NoError(err)
require.NoError(t, err)
is.Equal(test.expected, object)
})
}
@@ -172,12 +172,12 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
data, err := conn.MarshalObject(test.object)
is.NoError(err)
require.NoError(t, err)
var object []byte
err = conn.UnmarshalObject(data, &object)
is.NoError(err)
require.NoError(t, err)
is.Equal(test.object, object)
})
}

View File

@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/stretchr/testify/require"
)
const testBucketName = "test-bucket"
@@ -17,70 +18,55 @@ type testStruct struct {
}
func TestTxs(t *testing.T) {
conn := DbConnection{
Path: t.TempDir(),
}
conn := DbConnection{Path: t.TempDir()}
err := conn.Open()
if err != nil {
t.Fatal(err)
}
defer conn.Close()
require.NoError(t, err)
defer func() {
err := conn.Close()
require.NoError(t, err)
}()
// Error propagation
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return errors.New("this is an error")
})
if err == nil {
t.Fatal("an error was expected, got nil instead")
}
require.Error(t, err)
// Create an object
newObj := testStruct{
Key: "key",
Value: "value",
}
newObj := testStruct{Key: "key", Value: "value"}
err = conn.UpdateTx(func(tx portainer.Transaction) error {
err = tx.SetServiceName(testBucketName)
if err != nil {
if err := tx.SetServiceName(testBucketName); err != nil {
return err
}
return tx.CreateObjectWithId(testBucketName, testId, newObj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
obj := testStruct{}
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if obj.Key != newObj.Key || obj.Value != newObj.Value {
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
}
// Update an object
updatedObj := testStruct{
Key: "updated-key",
Value: "updated-value",
}
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
})
require.NoError(t, err)
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
@@ -90,16 +76,12 @@ func TestTxs(t *testing.T) {
err = conn.UpdateTx(func(tx portainer.Transaction) error {
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
})
if !dataservices.IsErrObjectNotFound(err) {
t.Fatal(err)
}
require.True(t, dataservices.IsErrObjectNotFound(err))
// Get next identifier
err = conn.UpdateTx(func(tx portainer.Transaction) error {
@@ -112,15 +94,11 @@ func TestTxs(t *testing.T) {
return nil
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
// Try to write in a read transaction
err = conn.ViewTx(func(tx portainer.Transaction) error {
return tx.CreateObjectWithId(testBucketName, testId, newObj)
})
if err == nil {
t.Fatal("an error was expected, got nil instead")
}
require.Error(t, err)
}

View File

@@ -0,0 +1,24 @@
package database
import (
"testing"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/require"
)
func TestNewDatabase(t *testing.T) {
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
connection, err := NewDatabase("boltdb", dbPath, nil, false)
require.NoError(t, err)
require.NotNil(t, connection)
_, ok := connection.(*boltdb.DbConnection)
require.True(t, ok)
connection, err = NewDatabase("unknown", dbPath, nil, false)
require.Error(t, err)
require.Nil(t, connection)
}

View File

@@ -21,7 +21,7 @@ type mockConnection struct {
portainer.Connection
}
func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error {
func (m mockConnection) UpdateObject(bucket string, key []byte, value any) error {
obj := value.(*testObject)
m.store[obj.ID] = *obj
@@ -50,7 +50,6 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func TestReadAll(t *testing.T) {
service := BaseDataService[testObject, int]{
Bucket: "testBucket",

View File

@@ -72,3 +72,13 @@ func (service BaseDataServiceTx[T, I]) Delete(ID I) error {
identifier := service.Connection.ConvertToKey(int(ID))
return service.Tx.DeleteObject(service.Bucket, identifier)
}
func Read[T any](tx portainer.Transaction, bucket string, key []byte) (*T, error) {
var element T
if err := tx.GetObject(bucket, key, &element); err != nil {
return nil, err
}
return &element, nil
}

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
@@ -14,7 +15,7 @@ func TestUpdate(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn, func(portainer.Transaction, portainer.EdgeStackID) {})
require.NoError(t, err)

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()

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
@@ -20,7 +21,7 @@ func TestUpdateRelation(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
@@ -109,7 +110,7 @@ func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
@@ -128,7 +129,7 @@ func TestEndpointRelations(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
service, err := NewService(conn)
require.NoError(t, err)
@@ -136,5 +137,5 @@ func TestEndpointRelations(t *testing.T) {
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1}))
rels, err := service.EndpointRelations()
require.NoError(t, err)
require.Equal(t, 1, len(rels))
require.Len(t, rels, 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")
)

View File

@@ -102,6 +102,9 @@ type (
// EndpointService represents a service for managing environment(endpoint) data
EndpointService interface {
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
@@ -223,6 +226,7 @@ type (
UserService interface {
BaseCRUD[portainer.User, portainer.UserID]
UserByUsername(username string) (*portainer.User, error)
UserIDByUsername(username string) (portainer.UserID, error)
UsersByRole(role portainer.UserRole) ([]portainer.User, error)
}

View File

@@ -3,6 +3,7 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -64,11 +65,9 @@ func (service *Service) ResourceControlByResourceIDAndType(resourceID string, re
return nil, stop
}
for _, subResourceID := range rc.SubResourceIDs {
if subResourceID == resourceID {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil

View File

@@ -3,6 +3,7 @@ package resourcecontrol
import (
"errors"
"fmt"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -35,11 +36,9 @@ func (service ServiceTx) ResourceControlByResourceIDAndType(resourceID string, r
return nil, stop
}
for _, subResourceID := range rc.SubResourceIDs {
if subResourceID == resourceID {
resourceControl = rc
return nil, stop
}
if slices.Contains(rc.SubResourceIDs, resourceID) {
resourceControl = rc
return nil, stop
}
return &portainer.ResourceControl{}, nil

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

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

View File

@@ -4,17 +4,18 @@ import (
"testing"
"time"
"github.com/portainer/portainer/api/datastore"
"github.com/gofrs/uuid"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newGuidString(t *testing.T) string {
uuid, err := uuid.NewV4()
assert.NoError(t, err)
uuid, err := uuid.NewRandom()
require.NoError(t, err)
return uuid.String()
}
@@ -41,7 +42,7 @@ func TestService_StackByWebhookID(t *testing.T) {
// can find a stack by webhook ID
got, err := store.StackService.StackByWebhookID(webhookID)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, stack, *got)
// returns nil and object not found error if there's no stack associated with the webhook
@@ -94,10 +95,10 @@ func Test_RefreshableStacks(t *testing.T) {
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
err := store.Stack().Create(stack)
assert.NoError(t, err)
require.NoError(t, err)
}
stacks, err := store.Stack().RefreshableStacks()
assert.NoError(t, err)
require.NoError(t, err)
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
}

View File

@@ -5,7 +5,9 @@ import (
"github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_teamByName(t *testing.T) {
@@ -13,7 +15,7 @@ func Test_teamByName(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
_, err := store.Team().TeamByName("name")
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
require.ErrorIs(t, err, errors.ErrObjectNotFound)
})
@@ -29,7 +31,7 @@ func Test_teamByName(t *testing.T) {
teamBuilder.createNew("name1")
_, err := store.Team().TeamByName("name")
assert.ErrorIs(t, err, errors.ErrObjectNotFound)
require.ErrorIs(t, err, errors.ErrObjectNotFound)
})
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
@@ -44,7 +46,7 @@ func Test_teamByName(t *testing.T) {
expectedTeam := teamBuilder.createNew("name1")
team, err := store.Team().TeamByName("name1")
assert.NoError(t, err, "TeamByName should succeed")
require.NoError(t, err, "TeamByName should succeed")
assert.Equal(t, expectedTeam, team)
})
}

View File

@@ -36,6 +36,18 @@ func (service ServiceTx) UserByUsername(username string) (*portainer.User, error
return nil, err
}
func (service ServiceTx) UserIDByUsername(username string) (portainer.UserID, error) {
user, err := service.UserByUsername(username)
if err != nil {
return 0, err
}
if user == nil {
return 0, dserrors.ErrObjectNotFound
}
return user.ID, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service ServiceTx) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)

View File

@@ -65,6 +65,18 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error)
return nil, err
}
func (service *Service) UserIDByUsername(username string) (portainer.UserID, error) {
user, err := service.UserByUsername(username)
if err != nil {
return 0, err
}
if user == nil {
return 0, dserrors.ErrObjectNotFound
}
return user.ID, nil
}
// UsersByRole return an array containing all the users with the specified role.
func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
var users = make([]portainer.User, 0)

View File

@@ -0,0 +1,70 @@
package version
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[models.Version, int] // ID is not used
}
func (tx ServiceTx) InstanceID() (string, error) {
v, err := tx.Version()
if err != nil {
return "", err
}
return v.InstanceID, nil
}
func (tx ServiceTx) UpdateInstanceID(ID string) error {
v, err := tx.Version()
if err != nil {
if !dataservices.IsErrObjectNotFound(err) {
return err
}
v = &models.Version{}
}
v.InstanceID = ID
return tx.UpdateVersion(v)
}
func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) {
v, err := tx.Version()
if err != nil {
return 0, err
}
return portainer.SoftwareEdition(v.Edition), nil
}
func (tx ServiceTx) Version() (*models.Version, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return nil, err
}
return &v, nil
}
func (tx ServiceTx) UpdateVersion(version *models.Version) error {
return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version)
}
func (tx ServiceTx) SchemaVersion() (string, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return "", err
}
return v.SchemaVersion, nil
}

View File

@@ -33,6 +33,16 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{
Bucket: BucketName,
Connection: service.connection,
Tx: tx,
},
}
}
func (service *Service) SchemaVersion() (string, error) {
v, err := service.Version()
if err != nil {

View File

@@ -14,33 +14,40 @@ import (
// corruption and if a path is not given a default is used.
// The path or an error are returned.
func (store *Store) Backup(path string) (string, error) {
if err := store.Close(); err != nil {
return "", fmt.Errorf("failed to close store before backup: %w", err)
}
filename, err := store.backupDBFile(path)
if err != nil {
return "", err
}
if _, err := store.Open(); err != nil {
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
}
return filename, nil
}
// backupDBFile copies the database file to the backup location.
// Does not manage connection state - works with the database file directly regardless of connection state.
func (store *Store) backupDBFile(backupPath string) (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
if path != "" {
backupFilename = path
}
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
// Close the store before backing up
err := store.Close()
if err != nil {
return "", fmt.Errorf("failed to close store before backup: %w", err)
if backupPath != "" {
backupFilename = backupPath
}
err = store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
if err != nil {
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msg("Backing up database")
if err := store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true); err != nil {
return "", fmt.Errorf("failed to create backup file: %w", err)
}
// reopen the store
_, err = store.Open()
if err != nil {
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
}
return backupFilename, nil
}
@@ -50,15 +57,17 @@ func (store *Store) Restore() error {
}
func (store *Store) RestoreFromFile(backupFilename string) error {
store.Close()
if err := store.Close(); err != nil {
return err
}
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
return fmt.Errorf("unable to restore backup file %q. err: %w", backupFilename, err)
}
log.Info().Str("from", backupFilename).Str("to", store.connection.GetDatabaseFilePath()).Msgf("database restored")
_, err := store.Open()
if err != nil {
if _, err := store.Open(); err != nil {
return fmt.Errorf("unable to determine version of restored portainer backup file: %w", err)
}
@@ -80,6 +89,7 @@ func (store *Store) createBackupPath() error {
return fmt.Errorf("unable to create backup folder: %w", err)
}
}
return nil
}

View File

@@ -1,19 +1,20 @@
package datastore
import (
"os"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/database/models"
"github.com/stretchr/testify/require"
"github.com/rs/zerolog/log"
)
func TestStoreCreation(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
if store == nil {
t.Fatal("Expect to create a store")
}
require.NotNil(t, store)
v, err := store.VersionService.Version()
if err != nil {
@@ -37,8 +38,12 @@ func TestBackup(t *testing.T) {
Edition: int(portainer.PortainerCE),
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
require.NoError(t, err)
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
@@ -54,10 +59,14 @@ func TestRestore(t *testing.T) {
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
_, err := store.Backup("")
require.NoError(t, err)
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.Restore()
require.NoError(t, err)
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
@@ -67,13 +76,65 @@ func TestRestore(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
_, err := store.Backup("")
require.NoError(t, err)
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
err = store.Restore()
require.NoError(t, err)
// check if the restore is successful and the version is correct
testVersion(store, "2.4", t)
})
}
func TestBackupDBFile(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("creates backup file without managing connection state", func(t *testing.T) {
// Verify connection is usable before
_, err := store.VersionService.Version()
require.NoError(t, err, "connection should be usable before backupDBFile")
// backupDBFile should work without closing the connection
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)
require.FileExists(t, backupFilename)
// Verify connection is still usable after (not closed/reopened)
_, err = store.VersionService.Version()
require.NoError(t, err, "connection should still be usable after backupDBFile")
require.NoError(t, os.Remove(backupFilename))
})
t.Run("uses custom path when provided", func(t *testing.T) {
customPath := t.TempDir() + "/custom-backup.db"
backupFilename, err := store.backupDBFile(customPath)
require.NoError(t, err)
require.Equal(t, customPath, backupFilename)
require.FileExists(t, backupFilename)
})
}
func TestBackupDBFileUsesCorrectPath(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
store.connection.SetEncrypted(false)
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)
require.FileExists(t, backupFilename)
// Verify it backed up the unencrypted file (portainer.db)
require.Contains(t, backupFilename, boltdb.DatabaseFileName)
require.NotContains(t, backupFilename, boltdb.EncryptedDatabaseFileName)
require.NoError(t, os.Remove(backupFilename))
})
}

View File

@@ -32,34 +32,38 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup("")
// NeedsEncryptionMigration() sets encrypted=true as a side effect when a key exists.
// We need to set it back to false so GetDatabaseFilePath() returns the path to the
// actual unencrypted file (portainer.db) that we want to back up.
store.connection.SetEncrypted(false)
// Use backupDBFile directly since connection isn't open yet
// and we don't want to trigger the close/open cycle of Backup()
backupFilename, err := store.backupDBFile("")
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}
err = store.encryptDB()
if err != nil {
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, err
if err := store.encryptDB(); err != nil {
innerErr := store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
return false, errors.Join(err, innerErr)
}
}
err = store.connection.Open()
if err != nil {
if err := store.connection.Open(); err != nil {
return false, err
}
err = store.initServices()
if err != nil {
if err := store.initServices(); err != nil {
return false, err
}
// If no settings object exists then assume we have a new store
_, err = store.SettingsService.Settings()
if err != nil {
if _, err := store.SettingsService.Settings(); err != nil {
if store.IsErrObjectNotFound(err) {
return true, nil
}
return false, err
}
@@ -72,19 +76,13 @@ func (store *Store) Close() error {
func (store *Store) UpdateTx(fn func(dataservices.DataStoreTx) error) error {
return store.connection.UpdateTx(func(tx portainer.Transaction) error {
return fn(&StoreTx{
store: store,
tx: tx,
})
return fn(&StoreTx{store: store, tx: tx})
})
}
func (store *Store) ViewTx(fn func(dataservices.DataStoreTx) error) error {
return store.connection.ViewTx(func(tx portainer.Transaction) error {
return fn(&StoreTx{
store: store,
tx: tx,
})
return fn(&StoreTx{store: store, tx: tx})
})
}
@@ -99,6 +97,7 @@ func (store *Store) CheckCurrentEdition() error {
if store.edition() != portainer.Edition {
return portainerErrors.ErrWrongDBEdition
}
return nil
}
@@ -107,6 +106,7 @@ func (store *Store) edition() portainer.SoftwareEdition {
if store.IsErrObjectNotFound(err) {
edition = portainer.PortainerCE
}
return edition
}
@@ -125,13 +125,11 @@ func (store *Store) Rollback(force bool) error {
func (store *Store) encryptDB() error {
store.connection.SetEncrypted(false)
err := store.connection.Open()
if err != nil {
if err := store.connection.Open(); err != nil {
return err
}
err = store.initServices()
if err != nil {
if err := store.initServices(); err != nil {
return err
}
@@ -144,8 +142,7 @@ func (store *Store) encryptDB() error {
log.Info().Str("filename", exportFilename).Msg("exporting database backup")
err = store.Export(exportFilename)
if err != nil {
if err := store.Export(exportFilename); err != nil {
log.Error().Str("filename", exportFilename).Err(err).Msg("failed to export")
return err
@@ -154,38 +151,33 @@ func (store *Store) encryptDB() error {
log.Info().Msg("database backup exported")
// Close existing un-encrypted db so that we can delete the file later
store.connection.Close()
// Tell the db layer to create an encrypted db when opened
store.connection.SetEncrypted(true)
store.connection.Open()
// We have to init services before import
err = store.initServices()
if err != nil {
if err := store.connection.Close(); err != nil {
return err
}
err = store.Import(exportFilename)
if err != nil {
if err := store.Import(exportFilename); err != nil {
log.Error().Err(err).Msg("failed to import database backup")
// Remove the new encrypted file that we failed to import
os.Remove(store.connection.GetDatabaseFilePath())
if err := os.Remove(store.connection.GetDatabaseFilePath()); err != nil {
log.Error().Msg("failed to remove the file after import failure")
}
log.Fatal().Err(portainerErrors.ErrDBImportFailed).Msg("")
}
err = os.Remove(oldFilename)
if err != nil {
if err := os.Remove(oldFilename); err != nil {
log.Error().Msg("failed to remove the un-encrypted db file")
}
err = os.Remove(exportFilename)
if err != nil {
if err := os.Remove(exportFilename); err != nil {
log.Error().Msg("failed to remove the json backup file")
}
// Close db connection
store.connection.Close()
if err := store.connection.Close(); err != nil {
return err
}
log.Info().Msg("database successfully encrypted")

View File

@@ -6,12 +6,14 @@ import (
"strings"
"testing"
"github.com/dchest/uniuri"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/crypto"
"github.com/dchest/uniuri"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
@@ -30,56 +32,32 @@ func TestStoreFull(t *testing.T) {
_, store := MustNewTestStore(t, true, true)
testCases := map[string]func(t *testing.T){
"User Accounts": func(t *testing.T) {
store.testUserAccounts(t)
},
"Environments": func(t *testing.T) {
store.testEnvironments(t)
},
"Settings": func(t *testing.T) {
store.testSettings(t)
},
"SSL Settings": func(t *testing.T) {
store.testSSLSettings(t)
},
"Tunnel Server": func(t *testing.T) {
store.testTunnelServer(t)
},
"Custom Templates": func(t *testing.T) {
store.testCustomTemplates(t)
},
"Registries": func(t *testing.T) {
store.testRegistries(t)
},
"Resource Control": func(t *testing.T) {
store.testResourceControl(t)
},
"Schedules": func(t *testing.T) {
store.testSchedules(t)
},
"Tags": func(t *testing.T) {
store.testTags(t)
},
// "Test Title": func(t *testing.T) {
// },
"User Accounts": store.testUserAccounts,
"Environments": store.testEnvironments,
"Settings": store.testSettings,
"SSL Settings": store.testSSLSettings,
"Tunnel Server": store.testTunnelServer,
"Custom Templates": store.testCustomTemplates,
"Registries": store.testRegistries,
"Resource Control": store.testResourceControl,
"Schedules": store.testSchedules,
"Tags": store.testTags,
}
for name, test := range testCases {
t.Run(name, test)
}
}
func (store *Store) testEnvironments(t *testing.T) {
id := store.CreateEndpoint(t, "local", portainer.KubernetesLocalEnvironment, "", true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
id = store.CreateEndpoint(t, "agent", portainer.AgentOnDockerEnvironment, agentOnDockerEnvironmentUrl, true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
id = store.CreateEndpoint(t, "edge", portainer.EdgeAgentOnKubernetesEnvironment, edgeAgentOnKubernetesEnvironmentUrl, true)
store.CreateEndpointRelation(id)
store.CreateEndpointRelation(t, id)
}
func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, name, URL string, TLS bool) *portainer.Endpoint {
@@ -112,18 +90,7 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n
}
func setEndpointAuthorizations(endpoint *portainer.Endpoint) {
endpoint.SecuritySettings = portainer.EndpointSecuritySettings{
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
AllowContainerCapabilitiesForRegularUsers: true,
AllowDeviceMappingForRegularUsers: true,
AllowStackManagementForRegularUsers: true,
}
endpoint.SecuritySettings = portainer.DefaultEndpointSecuritySettings()
}
func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType portainer.EndpointType, URL string, tls bool) portainer.EndpointID {
@@ -164,22 +131,25 @@ func (store *Store) CreateEndpoint(t *testing.T, name string, endpointType porta
}
setEndpointAuthorizations(expectedEndpoint)
store.Endpoint().Create(expectedEndpoint)
err := store.Endpoint().Create(expectedEndpoint)
require.NoError(t, err)
endpoint, err := store.Endpoint().Endpoint(id)
is.NoError(err, "Endpoint() should not return an error")
require.NoError(t, err, "Endpoint() should not return an error")
is.Equal(expectedEndpoint, endpoint, "endpoint should be the same")
return endpoint.ID
}
func (store *Store) CreateEndpointRelation(id portainer.EndpointID) {
func (store *Store) CreateEndpointRelation(t *testing.T, id portainer.EndpointID) {
relation := &portainer.EndpointRelation{
EndpointID: id,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
store.EndpointRelation().Create(relation)
err := store.EndpointRelation().Create(relation)
require.NoError(t, err)
}
func (store *Store) testSSLSettings(t *testing.T) {
@@ -191,10 +161,11 @@ func (store *Store) testSSLSettings(t *testing.T) {
SelfSigned: true,
}
store.SSLSettings().UpdateSettings(ssl)
err := store.SSLSettings().UpdateSettings(ssl)
require.NoError(t, err)
settings, err := store.SSLSettings().Settings()
is.NoError(err, "Get sslsettings should succeed")
require.NoError(t, err, "Get sslsettings should succeed")
is.Equal(ssl, settings, "Stored SSLSettings should be the same as what is read out")
}
@@ -203,27 +174,27 @@ func (store *Store) testTunnelServer(t *testing.T) {
expectPrivateKeySeed := uniuri.NewLen(16)
err := store.TunnelServer().UpdateInfo(&portainer.TunnelServerInfo{PrivateKeySeed: expectPrivateKeySeed})
is.NoError(err, "UpdateInfo should have succeeded")
require.NoError(t, err, "UpdateInfo should have succeeded")
serverInfo, err := store.TunnelServer().Info()
is.NoError(err, "Info should have succeeded")
require.NoError(t, err, "Info should have succeeded")
is.Equal(expectPrivateKeySeed, serverInfo.PrivateKeySeed, "hashed passwords should not differ")
}
// add users, read them back and check the details are unchanged
func (store *Store) testUserAccounts(t *testing.T) {
is := assert.New(t)
err := store.createAccount(adminUsername, adminPassword, portainer.AdministratorRole)
is.NoError(err, "CreateAccount should succeed")
store.checkAccount(adminUsername, adminPassword, portainer.AdministratorRole)
is.NoError(err, "Account failure")
require.NoError(t, err, "CreateAccount should succeed")
err = store.checkAccount(adminUsername, adminPassword, portainer.AdministratorRole)
require.NoError(t, err, "Account failure")
err = store.createAccount(standardUsername, standardPassword, portainer.StandardUserRole)
is.NoError(err, "CreateAccount should succeed")
store.checkAccount(standardUsername, standardPassword, portainer.StandardUserRole)
is.NoError(err, "Account failure")
require.NoError(t, err, "CreateAccount should succeed")
err = store.checkAccount(standardUsername, standardPassword, portainer.StandardUserRole)
require.NoError(t, err, "Account failure")
}
// create an account with the provided details
@@ -238,12 +209,7 @@ func (store *Store) createAccount(username, password string, role portainer.User
return err
}
err = store.User().Create(user)
if err != nil {
return err
}
return nil
return store.User().Create(user)
}
func (store *Store) checkAccount(username, expectPassword string, expectRole portainer.UserRole) error {
@@ -260,12 +226,7 @@ func (store *Store) checkAccount(username, expectPassword string, expectRole por
// Check the password
cs := crypto.Service{}
expectPasswordHash, err := cs.Hash(expectPassword)
if err != nil {
return errors.Wrap(err, "hash failed")
}
if user.Password != expectPasswordHash {
if cs.CompareHashAndData(user.Password, expectPassword) != nil {
return fmt.Errorf("%s user password hash failure", user.Username)
}
@@ -277,7 +238,7 @@ func (store *Store) testSettings(t *testing.T) {
// since many settings are default and basically nil, I'm going to update some and read them back
expectedSettings, err := store.Settings().Settings()
is.NoError(err, "Settings() should not return an error")
require.NoError(t, err, "Settings() should not return an error")
expectedSettings.TemplatesURL = "http://portainer.io/application-templates"
expectedSettings.HelmRepositoryURL = "http://portainer.io/helm-repository"
expectedSettings.EdgeAgentCheckinInterval = 60
@@ -291,10 +252,10 @@ func (store *Store) testSettings(t *testing.T) {
expectedSettings.SnapshotInterval = "10m"
err = store.Settings().UpdateSettings(expectedSettings)
is.NoError(err, "UpdateSettings() should succeed")
require.NoError(t, err, "UpdateSettings() should succeed")
settings, err := store.Settings().Settings()
is.NoError(err, "Settings() should not return an error")
require.NoError(t, err, "Settings() should not return an error")
is.Equal(expectedSettings, settings, "stored settings should match")
}
@@ -314,10 +275,11 @@ func (store *Store) testCustomTemplates(t *testing.T) {
CreatedByUserID: 10,
}
customTemplate.Create(expectedTemplate)
err := customTemplate.Create(expectedTemplate)
require.NoError(t, err)
actualTemplate, err := customTemplate.Read(expectedTemplate.ID)
is.NoError(err, "CustomTemplate should not return an error")
require.NoError(t, err, "CustomTemplate should not return an error")
is.Equal(expectedTemplate, actualTemplate, "expected and actual template do not match")
}
@@ -345,17 +307,17 @@ func (store *Store) testRegistries(t *testing.T) {
}
err := regService.Create(reg1)
is.NoError(err)
require.NoError(t, err)
err = regService.Create(reg2)
is.NoError(err)
require.NoError(t, err)
actualReg1, err := regService.Read(reg1.ID)
is.NoError(err)
require.NoError(t, err)
is.Equal(reg1, actualReg1, "registries differ")
actualReg2, err := regService.Read(reg2.ID)
is.NoError(err)
require.NoError(t, err)
is.Equal(reg2, actualReg2, "registries differ")
}
@@ -378,10 +340,10 @@ func (store *Store) testSchedules(t *testing.T) {
}
err := schedule.CreateSchedule(s)
is.NoError(err, "CreateSchedule should succeed")
require.NoError(t, err, "CreateSchedule should succeed")
actual, err := schedule.Schedule(s.ID)
is.NoError(err, "schedule should be found")
require.NoError(t, err, "schedule should be found")
is.Equal(s, actual, "schedules differ")
}
@@ -401,16 +363,16 @@ func (store *Store) testTags(t *testing.T) {
}
err := tags.Create(tag1)
is.NoError(err, "Tags.Create should succeed")
require.NoError(t, err, "Tags.Create should succeed")
err = tags.Create(tag2)
is.NoError(err, "Tags.Create should succeed")
require.NoError(t, err, "Tags.Create should succeed")
actual, err := tags.Read(tag1.ID)
is.NoError(err, "tag1 should be found")
require.NoError(t, err, "tag1 should be found")
is.Equal(tag1, actual, "tags differ")
actual, err = tags.Read(tag2.ID)
is.NoError(err, "tag2 should be found")
require.NoError(t, err, "tag2 should be found")
is.Equal(tag2, actual, "tags differ")
}

View File

@@ -31,7 +31,6 @@ func (store *Store) checkOrCreateDefaultSettings() error {
settings, err := store.SettingsService.Settings()
if store.IsErrObjectNotFound(err) {
defaultSettings := &portainer.Settings{
EnableTelemetry: false,
AuthenticationMethod: portainer.AuthenticationInternal,
BlackListedLabels: make([]portainer.Pair, 0),
InternalAuthSettings: portainer.InternalAuthSettings{

View File

@@ -9,14 +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/Masterminds/semver/v3"
"github.com/google/go-cmp/cmp"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestMigrateData(t *testing.T) {
@@ -53,9 +54,11 @@ func TestMigrateData(t *testing.T) {
}
testVersion(store, portainer.APIVersion, t)
store.Close()
err := store.Close()
require.NoError(t, err)
newStore, _ = store.Open()
newStore, err = store.Open()
require.NoError(t, err)
if newStore {
t.Error("Expect store to NOT be new DB")
}
@@ -63,8 +66,11 @@ func TestMigrateData(t *testing.T) {
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
store.MigrateData()
err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "2.0", Edition: int(portainer.PortainerCE)})
require.NoError(t, err)
err = store.MigrateData()
require.NoError(t, err)
backupfilename := store.backupFilename()
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
@@ -73,21 +79,28 @@ func TestMigrateData(t *testing.T) {
})
t.Run("MigrateData should recover and restore backup during migration critical failure", func(t *testing.T) {
os.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
t.Setenv("PORTAINER_TEST_MIGRATE_FAIL", "FAIL")
version := "2.15"
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
store.MigrateData()
store.Open()
err := store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
require.NoError(t, err)
err = store.MigrateData()
require.Error(t, err)
testVersion(store, version, t)
})
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.StoreIsUpdating(true)
store.MigrateData()
err := store.VersionService.StoreIsUpdating(true)
require.NoError(t, err)
err = store.MigrateData()
require.Error(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -115,10 +128,12 @@ func TestMigrateData(t *testing.T) {
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
v.MigratorCount = len(latestMigrations.MigrationFuncs)
store.VersionService.UpdateVersion(v)
err = store.VersionService.UpdateVersion(v)
require.NoError(t, err)
}
store.MigrateData()
err = store.MigrateData()
require.NoError(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -141,8 +156,12 @@ func TestMigrateData(t *testing.T) {
}
v.MigratorCount = 1000
store.VersionService.UpdateVersion(v)
store.MigrateData()
err = store.VersionService.UpdateVersion(v)
require.NoError(t, err)
err = store.MigrateData()
require.NoError(t, err)
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
// If the backup file is not blank, then it means a backup was created. We don't want that because we
@@ -158,14 +177,14 @@ func TestRollback(t *testing.T) {
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
version := "2.11"
v := models.Version{
SchemaVersion: version,
}
v := models.Version{SchemaVersion: version}
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -184,7 +203,9 @@ func TestRollback(t *testing.T) {
return
}
store.Open()
_, err = store.Open()
require.NoError(t, err)
testVersion(store, version, t)
})
@@ -197,9 +218,11 @@ func TestRollback(t *testing.T) {
}
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
err := store.VersionService.UpdateVersion(&v)
require.NoError(t, err)
_, err = store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -218,7 +241,8 @@ func TestRollback(t *testing.T) {
return
}
store.Open()
_, err = store.Open()
require.NoError(t, err)
testVersion(store, version, t)
})
}
@@ -237,17 +261,17 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
_, store := MustNewTestStore(t, true, false)
fmt.Println("store.path=", store.GetConnection().GetDatabaseFilePath())
store.connection.DeleteObject("version", []byte("VERSION"))
err = store.connection.DeleteObject("version", []byte("VERSION"))
require.NoError(t, err)
// defer teardown()
err = importJSON(t, bytes.NewReader(srcJSON), store)
if err != nil {
if err := importJSON(t, bytes.NewReader(srcJSON), store); err != nil {
return err
}
// Run the actual migrations on our input database.
err = store.MigrateData()
if err != nil {
if err := store.MigrateData(); err != nil {
return err
}
@@ -260,8 +284,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
}
v.InstanceID = "463d5c47-0ea5-4aca-85b1-405ceefee254"
err = store.VersionService.UpdateVersion(v)
if err != nil {
if err := store.VersionService.UpdateVersion(v); err != nil {
return err
}
}
@@ -270,10 +293,10 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
// exportJson rather than ExportRaw. The exportJson function allows us to
// strip out the metadata which we don't want for our tests.
// TODO: update connection interface in CE to allow us to use ExportRaw and pass meta false
err = store.connection.Close()
if err != nil {
if err := store.connection.Close(); err != nil {
t.Fatalf("err closing bolt connection: %v", err)
}
con, ok := store.connection.(*boltdb.DbConnection)
if !ok {
t.Fatalf("backing database is not using boltdb, but the migrations test requires it")
@@ -302,11 +325,15 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
// Compare the result we got with the one we wanted.
if diff := cmp.Diff(wantJSON, gotJSON); diff != "" {
gotPath := filepath.Join(os.TempDir(), "portainer-migrator-test-fail.json")
os.WriteFile(
err = os.WriteFile(
gotPath,
gotJSON,
0o600,
)
if err != nil {
log.Warn().Err(err).Msg("failed writing migrated output to temp file")
}
t.Errorf(
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",
srcPath,

View File

@@ -6,7 +6,9 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore/migrator"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMigrateStackEntryPoint(t *testing.T) {
@@ -28,25 +30,25 @@ func TestMigrateStackEntryPoint(t *testing.T) {
for _, s := range stacks {
err := stackService.Create(s)
assert.NoError(t, err, "failed to create stack")
require.NoError(t, err, "failed to create stack")
}
s, err := stackService.Read(1)
assert.NoError(t, err)
require.NoError(t, err)
assert.Nil(t, s.GitConfig, "first stack should not have git config")
s, err = stackService.Read(2)
assert.NoError(t, err)
assert.Equal(t, "", s.GitConfig.ConfigFilePath, "not migrated yet migrated")
require.NoError(t, err)
assert.Empty(t, s.GitConfig.ConfigFilePath, "not migrated yet migrated")
err = migrator.MigrateStackEntryPoint(stackService)
assert.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
require.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
s, err = stackService.Read(1)
assert.NoError(t, err)
require.NoError(t, err)
assert.Nil(t, s.GitConfig, "first stack should not have git config")
s, err = stackService.Read(2)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated")
}

View File

@@ -105,12 +105,18 @@ func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// finishMigrateLegacyVersion writes the new version to the DB and removes the old version keys from the DB
func (store *Store) finishMigrateLegacyVersion(versionToWrite *models.Version) error {
err := store.VersionService.UpdateVersion(versionToWrite)
if err := store.VersionService.UpdateVersion(versionToWrite); err != nil {
return err
}
// Remove legacy keys if present
store.connection.DeleteObject(bucketName, []byte(legacyDBVersionKey))
store.connection.DeleteObject(bucketName, []byte(legacyEditionKey))
store.connection.DeleteObject(bucketName, []byte(legacyInstanceKey))
if err := store.connection.DeleteObject(bucketName, []byte(legacyDBVersionKey)); err != nil {
return err
}
return err
if err := store.connection.DeleteObject(bucketName, []byte(legacyEditionKey)); err != nil {
return err
}
return store.connection.DeleteObject(bucketName, []byte(legacyInstanceKey))
}

View File

@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
@@ -15,7 +16,7 @@ func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
defer logs.CloseAndLogErr(conn)
edgeGroupService, err := edgegroup.NewService(conn)
require.NoError(t, err)
@@ -39,7 +40,7 @@ func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
migratedEdgeGroup, err := edgeGroupService.Read(edgeGroup.ID)
require.NoError(t, err)
require.Len(t, migratedEdgeGroup.Endpoints, 0)
require.Empty(t, migratedEdgeGroup.Endpoints)
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
// Run migration again to ensure the results didn't change
@@ -50,6 +51,6 @@ func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
migratedEdgeGroup, err = edgeGroupService.Read(edgeGroup.ID)
require.NoError(t, err)
require.Len(t, migratedEdgeGroup.Endpoints, 0)
require.Empty(t, migratedEdgeGroup.Endpoints)
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
}

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
}

View File

@@ -21,7 +21,6 @@ func (m *Migrator) updateSettingsToDB25() error {
}
legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
legacySettings.EnableTelemetry = true
legacySettings.AllowContainerCapabilitiesForRegularUsers = true

View File

@@ -77,8 +77,12 @@ func (m *Migrator) updateRegistriesToDB32() error {
Namespaces: []string{},
}
}
m.registryService.Update(registry.ID, &registry)
if err := m.registryService.Update(registry.ID, &registry); err != nil {
return err
}
}
return nil
}
@@ -121,10 +125,11 @@ func (m *Migrator) updateDockerhubToDB32() error {
if !migrated {
// keep this one entry
migrated = true
} else {
// delete subsequent duplicates
m.registryService.Delete(r.ID)
} else if err := m.registryService.Delete(r.ID); err != nil {
return err
}
}
}
@@ -138,7 +143,6 @@ func (m *Migrator) updateDockerhubToDB32() error {
}
for _, endpoint := range endpoints {
if endpoint.Type != portainer.KubernetesLocalEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment &&
endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment {
@@ -146,18 +150,14 @@ func (m *Migrator) updateDockerhubToDB32() error {
userAccessPolicies := portainer.UserAccessPolicies{}
for userId := range endpoint.UserAccessPolicies {
if _, found := endpoint.UserAccessPolicies[userId]; found {
userAccessPolicies[userId] = portainer.AccessPolicy{
RoleID: 0,
}
userAccessPolicies[userId] = portainer.AccessPolicy{RoleID: 0}
}
}
teamAccessPolicies := portainer.TeamAccessPolicies{}
for teamId := range endpoint.TeamAccessPolicies {
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
teamAccessPolicies[teamId] = portainer.AccessPolicy{
RoleID: 0,
}
teamAccessPolicies[teamId] = portainer.AccessPolicy{RoleID: 0}
}
}

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)
// WARNING: do not change migrations that have already been released!
// Add new migrations above...
// One function per migration, each versions migration funcs in the same file.
}

View File

@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/api/pendingactions/handlers"
"github.com/stretchr/testify/require"
)
type cleanNAPWithOverridePolicies struct {
@@ -16,7 +17,10 @@ func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
defer store.Close()
defer func() {
err := store.Close()
require.NoError(t, err)
}()
gid := portainer.EndpointGroupID(1)
@@ -92,7 +96,8 @@ func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
})
}
store.PendingActions().Delete(d.PendingAction.ID)
err = store.PendingActions().Delete(d.PendingAction.ID)
require.NoError(t, err)
}
})
}

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"
@@ -11,6 +13,7 @@ import (
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/pkg/endpoints"
@@ -43,40 +46,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
@@ -84,56 +112,73 @@ 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
err = migrator.MigrateIngresses(*environment, kubeclient)
if err != nil {
// 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
}
return nil
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
}
defer dockerClient.Close()
migrator.MigrateGPUs(*environment, dockerClient)
}
return nil
@@ -144,13 +189,20 @@ func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoin
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
}
@@ -160,29 +212,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
}
@@ -196,10 +261,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
}

View File

@@ -22,8 +22,9 @@ func TestMigrateGPUs(t *testing.T) {
if strings.HasSuffix(r.URL.Path, "/containers/json") {
containerSummary := []container.Summary{{ID: "container1"}}
err := json.NewEncoder(w).Encode(containerSummary)
require.NoError(t, err)
if err := json.NewEncoder(w).Encode(containerSummary); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
@@ -41,8 +42,9 @@ func TestMigrateGPUs(t *testing.T) {
},
}
err := json.NewEncoder(w).Encode(container)
require.NoError(t, err)
if err := json.NewEncoder(w).Encode(container); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer srv.Close()
@@ -129,12 +131,12 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
EdgeID: "edgeID",
}
err := store.Endpoint().Create(endpoint)
is.NoError(err, "error creating endpoint")
require.NoError(t, err, "error creating endpoint")
// Create any existing pending actions
for _, action := range tt.existingPendingActions {
err = store.PendingActions().Create(action)
is.NoError(err, "error creating pending action")
require.NoError(t, err, "error creating pending action")
}
migrator := NewPostInitMigrator(
@@ -146,11 +148,11 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
)
err = migrator.PostInitMigrate()
is.NoError(err, "PostInitMigrate should not return error")
require.NoError(t, err, "PostInitMigrate should not return error")
// Verify the results
pendingActions, err := store.PendingActions().ReadAll()
is.NoError(err, "error reading pending actions")
require.NoError(t, err, "error reading pending actions")
is.Len(pendingActions, tt.expectedPendingActions, "unexpected number of pending actions")
// If we expect any actions, verify at least one has the expected action type
@@ -160,9 +162,11 @@ func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
if action.Action == tt.expectedAction {
hasExpectedAction = true
is.Equal(endpoint.ID, action.EndpointID, "action should reference correct endpoint")
break
}
}
is.True(hasExpectedAction, "should have found action of expected type")
}
})

View File

@@ -391,16 +391,16 @@ type storeExport struct {
ResourceControl []portainer.ResourceControl `json:"resource_control,omitempty"`
Role []portainer.Role `json:"roles,omitempty"`
Schedules []portainer.Schedule `json:"schedules,omitempty"`
Settings portainer.Settings `json:"settings,omitempty"`
Settings portainer.Settings `json:"settings,omitzero"`
Snapshot []portainer.Snapshot `json:"snapshots,omitempty"`
SSLSettings portainer.SSLSettings `json:"ssl,omitempty"`
SSLSettings portainer.SSLSettings `json:"ssl,omitzero"`
Stack []portainer.Stack `json:"stacks,omitempty"`
Tag []portainer.Tag `json:"tags,omitempty"`
TeamMembership []portainer.TeamMembership `json:"team_membership,omitempty"`
Team []portainer.Team `json:"teams,omitempty"`
TunnelServer portainer.TunnelServerInfo `json:"tunnel_server,omitempty"`
TunnelServer portainer.TunnelServerInfo `json:"tunnel_server,omitzero"`
User []portainer.User `json:"users,omitempty"`
Version models.Version `json:"version,omitempty"`
Version models.Version `json:"version,omitzero"`
Webhook []portainer.Webhook `json:"webhooks,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@@ -625,85 +625,129 @@ func (store *Store) Import(filename string) (err error) {
return err
}
store.Version().UpdateVersion(&backup.Version)
err = store.Version().UpdateVersion(&backup.Version)
if err != nil {
return err
}
for _, v := range backup.CustomTemplate {
store.CustomTemplate().Update(v.ID, &v)
if err := store.CustomTemplate().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the custom template in the database")
}
}
for _, v := range backup.EdgeGroup {
store.EdgeGroup().Update(v.ID, &v)
if err := store.EdgeGroup().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the edge group in the database")
}
}
for _, v := range backup.EdgeJob {
store.EdgeJob().Update(v.ID, &v)
if err := store.EdgeJob().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the edge job in the database")
}
}
for _, v := range backup.EdgeStack {
store.EdgeStack().UpdateEdgeStack(v.ID, &v)
if err := store.EdgeStack().UpdateEdgeStack(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the edge stack in the database")
}
}
for _, v := range backup.Endpoint {
store.Endpoint().UpdateEndpoint(v.ID, &v)
if err := store.Endpoint().UpdateEndpoint(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the endpoint in the database")
}
}
for _, v := range backup.EndpointGroup {
store.EndpointGroup().Update(v.ID, &v)
if err := store.EndpointGroup().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the endpoint group in the database")
}
}
for _, v := range backup.EndpointRelation {
store.EndpointRelation().UpdateEndpointRelation(v.EndpointID, &v)
if err := store.EndpointRelation().UpdateEndpointRelation(v.EndpointID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the endpoint relation in the database")
}
}
for _, v := range backup.HelmUserRepository {
store.HelmUserRepository().Update(v.ID, &v)
if err := store.HelmUserRepository().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the helm user repository in the database")
}
}
for _, v := range backup.Registry {
store.Registry().Update(v.ID, &v)
if err := store.Registry().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the registry in the database")
}
}
for _, v := range backup.ResourceControl {
store.ResourceControl().Update(v.ID, &v)
if err := store.ResourceControl().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the resource control in the database")
}
}
for _, v := range backup.Role {
store.Role().Update(v.ID, &v)
if err := store.Role().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the role in the database")
}
}
store.Settings().UpdateSettings(&backup.Settings)
store.SSLSettings().UpdateSettings(&backup.SSLSettings)
if err := store.Settings().UpdateSettings(&backup.Settings); err != nil {
log.Warn().Err(err).Msg("failed to update the settings in the database")
}
if err := store.SSLSettings().UpdateSettings(&backup.SSLSettings); err != nil {
log.Warn().Err(err).Msg("failed to update the SSL settings in the database")
}
for _, v := range backup.Snapshot {
store.Snapshot().Update(v.EndpointID, &v)
if err := store.Snapshot().Update(v.EndpointID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the snapshot in the database")
}
}
for _, v := range backup.Stack {
store.Stack().Update(v.ID, &v)
if err := store.Stack().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the stack in the database")
}
}
for _, v := range backup.Tag {
store.Tag().Update(v.ID, &v)
if err := store.Tag().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the tag in the database")
}
}
for _, v := range backup.TeamMembership {
store.TeamMembership().Update(v.ID, &v)
if err := store.TeamMembership().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the team membership in the database")
}
}
for _, v := range backup.Team {
store.Team().Update(v.ID, &v)
if err := store.Team().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the team in the database")
}
}
store.TunnelServer().UpdateInfo(&backup.TunnelServer)
if err := store.TunnelServer().UpdateInfo(&backup.TunnelServer); err != nil {
log.Warn().Err(err).Msg("failed to update the tunnel server info in the database")
}
for _, user := range backup.User {
if err := store.User().Update(user.ID, &user); err != nil {
log.Debug().Str("user", fmt.Sprintf("%+v", user)).Err(err).Msg("failed to update the user in the database")
log.Warn().Str("user", fmt.Sprintf("%+v", user)).Err(err).Msg("failed to update the user in the database")
}
}
for _, v := range backup.Webhook {
store.Webhook().Update(v.ID, &v)
if err := store.Webhook().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the webhook in the database")
}
}
return store.connection.RestoreMetadata(backup.Metadata)

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)

View File

@@ -83,13 +83,13 @@
"MigrateIngresses": true
},
"PublicURL": "",
"QueryDate": 0,
"SecuritySettings": {
"allowBindMountsForRegularUsers": true,
"allowContainerCapabilitiesForRegularUsers": true,
"allowDeviceMappingForRegularUsers": true,
"allowHostNamespaceForRegularUsers": true,
"allowPrivilegedModeForRegularUsers": true,
"allowSecurityOptForRegularUsers": false,
"allowStackManagementForRegularUsers": true,
"allowSysctlSettingForRegularUsers": false,
"allowVolumeBrowserForRegularUsers": false,
@@ -604,7 +604,6 @@
"EdgeAgentCheckinInterval": 5,
"EdgePortainerUrl": "",
"EnableEdgeComputeFeatures": false,
"EnableTelemetry": true,
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"GlobalDeploymentOptions": {
@@ -615,7 +614,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.33.2",
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -944,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.33.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
@@ -143,11 +144,16 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
body, err := io.ReadAll(resp.Body)
if err != nil {
resp.Body.Close()
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
return resp, err
}
resp.Body.Close()
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
resp.Body = io.NopCloser(bytes.NewReader(body))

View File

@@ -8,8 +8,9 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"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"
@@ -75,7 +76,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
if err != nil {
return nil, errors.Wrap(err, "create client error")
}
defer cli.Close()
defer logs.CloseAndLogErr(cli)
log.Debug().Str("container_id", containerId).Msg("starting to fetch container information")
@@ -146,13 +147,19 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
c.sr.push(func() {
log.Debug().Str("container_id", containerId).Str("container", container.Name).Msg("restoring the container")
cli.ContainerRename(ctx, containerId, container.Name)
for _, network := range container.NetworkSettings.Networks {
cli.NetworkConnect(ctx, network.NetworkID, containerId, network)
if err := cli.ContainerRename(ctx, containerId, container.Name); err != nil {
log.Warn().Err(err).Msg("failure to rename container")
}
cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{})
for _, network := range container.NetworkSettings.Networks {
if err := cli.NetworkConnect(ctx, network.NetworkID, containerId, network); err != nil {
log.Warn().Err(err).Msg("failure to connect container to network")
}
}
if err := cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{}); err != nil {
log.Warn().Err(err).Msg("failure to start container")
}
})
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
@@ -175,8 +182,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
if err := cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}); err != nil {
log.Warn().Err(err).Msg("failure to stop container")
}
if err := cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{}); err != nil {
log.Warn().Err(err).Msg("failure to remove container")
}
})
if err != nil {

View File

@@ -1,37 +0,0 @@
package docker
import "github.com/docker/docker/api/types"
type ContainerStats struct {
Running int `json:"running"`
Stopped int `json:"stopped"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
Total int `json:"total"`
}
func CalculateContainerStats(containers []types.Container) ContainerStats {
var running, stopped, healthy, unhealthy int
for _, container := range containers {
switch container.State {
case "running":
running++
case "healthy":
running++
healthy++
case "unhealthy":
running++
unhealthy++
case "exited", "stopped":
stopped++
}
}
return ContainerStats{
Running: running,
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
}
}

View File

@@ -1,27 +0,0 @@
package docker
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/stretchr/testify/assert"
)
func TestCalculateContainerStats(t *testing.T) {
containers := []types.Container{
{State: "running"},
{State: "running"},
{State: "exited"},
{State: "stopped"},
{State: "healthy"},
{State: "unhealthy"},
}
stats := CalculateContainerStats(containers)
assert.Equal(t, 4, stats.Running)
assert.Equal(t, 2, stats.Stopped)
assert.Equal(t, 1, stats.Healthy)
assert.Equal(t, 1, stats.Unhealthy)
assert.Equal(t, 6, stats.Total)
}

View File

@@ -7,12 +7,12 @@ import (
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/containers/image/v5/docker"
imagetypes "github.com/containers/image/v5/types"
"github.com/docker/docker/api/types/image"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"go.podman.io/image/v5/docker"
imagetypes "go.podman.io/image/v5/types"
)
// Options holds docker registry object options

View File

@@ -7,11 +7,11 @@ import (
"strings"
"text/template"
"github.com/containers/image/v5/docker/reference"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"go.podman.io/image/v5/docker/reference"
)
type ImageID string

View File

@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestImageParser(t *testing.T) {
@@ -14,7 +15,7 @@ func TestImageParser(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "portainer/portainer-ee",
})
is.NoError(err, "")
require.NoError(t, err)
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
is.Equal("portainer/portainer-ee", image.Opts.Name)
is.Equal("latest", image.Tag)
@@ -30,10 +31,10 @@ func TestImageParser(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
require.NoError(t, err)
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("", image.Tag)
is.Empty(image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
is.Equal("https://gcr.io/k8s-minikube/kicbase", image.HubLink)
@@ -47,7 +48,7 @@ func TestImageParser(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
require.NoError(t, err)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)
@@ -68,8 +69,9 @@ func TestUpdateParsedImage(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
_ = image.WithTag("v0.0.31")
require.NoError(t, err)
err = image.WithTag("v0.0.31")
require.NoError(t, err)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.31", image.Tag)
@@ -86,8 +88,9 @@ func TestUpdateParsedImage(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
_ = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
require.NoError(t, err)
err = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
require.NoError(t, err)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)
@@ -104,8 +107,9 @@ func TestUpdateParsedImage(t *testing.T) {
image, err := ParseImage(ParseImageOptions{
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
_ = image.TrimDigest()
require.NoError(t, err)
err = image.TrimDigest()
require.NoError(t, err)
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)

View File

@@ -5,6 +5,7 @@ import (
"io"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
@@ -42,7 +43,7 @@ func (puller *Puller) Pull(ctx context.Context, img Image) error {
if err != nil {
return err
}
defer out.Close()
defer logs.CloseAndLogErr(out)
_, err = io.ReadAll(out)

View File

@@ -3,8 +3,8 @@ package images
import (
"strings"
"github.com/containers/image/v5/docker"
"github.com/containers/image/v5/types"
"go.podman.io/image/v5/docker"
"go.podman.io/image/v5/types"
)
func ParseReference(imageStr string) (types.ImageReference, error) {

View File

@@ -4,7 +4,9 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
@@ -15,9 +17,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", false),
createNewRegistry("hub-mirror.c.163.com", "", false)}
r, err := findBestMatchRegistry(image, registries)
is.NoError(err, "")
is.NotNil(r, "")
is.False(r.Authentication, "")
require.NoError(t, err)
is.NotNil(r)
is.False(r.Authentication)
is.Equal("docker.io", r.URL)
})
@@ -26,9 +28,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
registries := []portainer.Registry{createNewRegistry("docker.io", "", false),
createNewRegistry("hub-mirror.c.163.com", "USERNAME", false)}
r, err := findBestMatchRegistry(image, registries)
is.NoError(err, "")
is.NotNil(r, "")
is.False(r.Authentication, "")
require.NoError(t, err)
is.NotNil(r)
is.False(r.Authentication)
is.Equal("docker.io", r.URL)
})
@@ -37,9 +39,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
registries := []portainer.Registry{createNewRegistry("docker.io", "USERNAME", true),
createNewRegistry("hub-mirror.c.163.com", "", false)}
r, err := findBestMatchRegistry(image, registries)
is.NoError(err, "")
is.NotNil(r, "")
is.True(r.Authentication, "")
require.NoError(t, err)
is.NotNil(r)
is.True(r.Authentication)
is.Equal("docker.io", r.URL)
})
@@ -47,9 +49,9 @@ func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
image := "portainer/portainer-ee:latest"
registries := []portainer.Registry{createNewRegistry("docker.io", "", true)}
r, err := findBestMatchRegistry(image, registries)
is.NoError(err, "")
is.NotNil(r, "")
is.True(r.Authentication, "")
require.NoError(t, err)
is.NotNil(r)
is.True(r.Authentication)
is.Equal("docker.io", r.URL)
})
}

View File

@@ -280,13 +280,7 @@ func contains(statuses []Status, status Status) bool {
return false
}
for _, s := range statuses {
if s == status {
return true
}
}
return false
return slices.Contains(statuses, status)
}
func allMatch(statuses []Status, status Status) bool {

View File

@@ -3,6 +3,7 @@ package docker
import (
portainer "github.com/portainer/portainer/api"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/pkg/snapshot"
)
@@ -24,7 +25,7 @@ func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*p
if err != nil {
return nil, err
}
defer cli.Close()
defer logs.CloseAndLogErr(cli)
return snapshot.CreateDockerSnapshot(cli)
}

View File

@@ -0,0 +1,138 @@
package stats
import (
"context"
"errors"
"strings"
"sync"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
)
type ContainerStats struct {
Running int `json:"running"`
Stopped int `json:"stopped"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
Total int `json:"total"`
}
type DockerClient interface {
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
}
func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) {
if isSwarm {
return CalculateContainerStatsForSwarm(containers), nil
}
var running, stopped, healthy, unhealthy int
var mu sync.Mutex
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5)
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 }()
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
}
stat = getContainerStatus(containerInspection.State)
mu.Lock()
running += stat.Running
stopped += stat.Stopped
healthy += stat.Healthy
unhealthy += stat.Unhealthy
processedCount++
mu.Unlock()
})
}
wg.Wait()
return ContainerStats{
Running: running,
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: processedCount,
}, aggErr
}
func getContainerStatus(state *container.State) ContainerStats {
stat := ContainerStats{}
if state == nil {
return stat
}
switch state.Status {
case container.StateRunning:
stat.Running++
case container.StateExited, container.StateDead:
stat.Stopped++
}
if state.Health != nil {
switch state.Health.Status {
case container.Healthy:
stat.Healthy++
case container.Unhealthy:
stat.Unhealthy++
}
}
return stat
}
// This is a temporary workaround to calculate container stats for Swarm
// TODO: Remove this once we have a proper way to calculate container stats for Swarm
func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats {
var running, stopped, healthy, unhealthy int
for _, container := range containers {
switch container.State {
case "running":
running++
case "exited", "stopped":
stopped++
}
if strings.Contains(container.Status, "(healthy)") {
healthy++
} else if strings.Contains(container.Status, "(unhealthy)") {
unhealthy++
}
}
return ContainerStats{
Running: running,
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
}
}

View File

@@ -0,0 +1,251 @@
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"
"github.com/stretchr/testify/require"
)
// MockDockerClient implements the DockerClient interface for testing
type MockDockerClient struct {
mock.Mock
}
func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) {
args := m.Called(ctx, containerID)
return args.Get(0).(container.InspectResponse), args.Error(1)
}
func TestCalculateContainerStats(t *testing.T) {
mockClient := new(MockDockerClient)
// Test containers - using enough containers to test concurrent processing
containers := []container.Summary{
{ID: "container1"},
{ID: "container2"},
{ID: "container3"},
{ID: "container4"},
{ID: "container5"},
{ID: "container6"},
{ID: "container7"},
{ID: "container8"},
{ID: "container9"},
{ID: "container10"},
{ID: "container11"},
}
// Setup mock expectations with different container states to test various scenarios
containerStates := []struct {
id string
status string
health *container.Health
expected ContainerStats
}{
{"container1", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
{"container2", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
{"container3", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
{"container4", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
{"container5", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
{"container6", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
{"container7", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
{"container8", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
{"container9", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
}
// 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{
ContainerJSONBase: &container.ContainerJSONBase{
State: &container.State{
Status: state.status,
Health: state.health,
},
},
}, 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)
require.NoError(t, err, "failed to calculate container stats")
duration := time.Since(startTime)
// Assert results
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
mockClient.AssertExpectations(t)
// Test concurrency: With 5 workers and 10 containers taking 50ms each:
// Sequential would take: 10 * 50ms = 500ms
sequentialTime := 10 * 50 * time.Millisecond
// Verify that concurrent processing is actually faster than sequential
// Allow some overhead for goroutine scheduling
assert.Less(t, duration, sequentialTime, "Concurrent processing should be faster than sequential")
// Concurrent should take: ~100-150ms (depending on scheduling)
assert.Less(t, duration, 150*time.Millisecond, "Concurrent processing should be significantly faster")
assert.Greater(t, duration, 100*time.Millisecond, "Concurrent processing should be longer than 100ms")
}
func TestCalculateContainerStatsAllErrors(t *testing.T) {
mockClient := new(MockDockerClient)
// Test containers
containers := []container.Summary{
{ID: "container1"},
{ID: "container2"},
}
// Setup mock expectations with all calls returning errors
mockClient.On("ContainerInspect", mock.Anything, "container1").Return(container.InspectResponse{}, errors.New("network error"))
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
// Call the function
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
// Assert that an error was returned
require.Error(t, err, "should return error when all containers fail to inspect")
assert.Contains(t, err.Error(), "network error", "error should contain one of the original error messages")
assert.Contains(t, err.Error(), "permission denied", "error should contain the other original error message")
// Assert that stats are zero since no containers were successfully processed
expectedStats := ContainerStats{
Running: 0,
Stopped: 0,
Healthy: 0,
Unhealthy: 0,
Total: 2, // total containers processed
}
assert.Equal(t, expectedStats, stats)
// Verify all mock calls were made
mockClient.AssertExpectations(t)
}
func TestGetContainerStatus(t *testing.T) {
testCases := []struct {
name string
state *container.State
expected ContainerStats
}{
{
name: "running healthy container",
state: &container.State{
Status: container.StateRunning,
Health: &container.Health{
Status: container.Healthy,
},
},
expected: ContainerStats{
Running: 1,
Stopped: 0,
Healthy: 1,
Unhealthy: 0,
},
},
{
name: "running unhealthy container",
state: &container.State{
Status: container.StateRunning,
Health: &container.Health{
Status: container.Unhealthy,
},
},
expected: ContainerStats{
Running: 1,
Stopped: 0,
Healthy: 0,
Unhealthy: 1,
},
},
{
name: "running container without health check",
state: &container.State{
Status: container.StateRunning,
},
expected: ContainerStats{
Running: 1,
Stopped: 0,
Healthy: 0,
Unhealthy: 0,
},
},
{
name: "exited container",
state: &container.State{
Status: container.StateExited,
},
expected: ContainerStats{
Running: 0,
Stopped: 1,
Healthy: 0,
Unhealthy: 0,
},
},
{
name: "dead container",
state: &container.State{
Status: container.StateDead,
},
expected: ContainerStats{
Running: 0,
Stopped: 1,
Healthy: 0,
Unhealthy: 0,
},
},
{
name: "nil state",
state: nil,
expected: ContainerStats{
Running: 0,
Stopped: 0,
Healthy: 0,
Unhealthy: 0,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stat := getContainerStatus(testCase.state)
assert.Equal(t, testCase.expected, stat)
})
}
}
func TestCalculateContainerStatsForSwarm(t *testing.T) {
containers := []container.Summary{
{State: "running"},
{State: "running", Status: "Up 5 minutes (healthy)"},
{State: "exited"},
{State: "stopped"},
{State: "running", Status: "Up 10 minutes"},
{State: "running", Status: "Up about an hour (unhealthy)"},
}
stats := CalculateContainerStatsForSwarm(containers)
assert.Equal(t, 4, stats.Running)
assert.Equal(t, 2, stats.Stopped)
assert.Equal(t, 1, stats.Healthy)
assert.Equal(t, 1, stats.Unhealthy)
assert.Equal(t, 6, stats.Total)
}

View File

@@ -60,11 +60,26 @@ type (
// EnvVars is a list of environment variables to inject into the stack
EnvVars []portainer.Pair
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
// ForceUpdate is a flag indicating if the agent must force the update of the stack.
// Used only for EE
ForceUpdate bool
DeployerOptionsPayload DeployerOptionsPayload
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
// Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead
ReadyRePullImage bool
// CreatedBy is the username that created this stack
// Used for adding labels to Kubernetes manifests
CreatedBy string
// 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 {
@@ -77,6 +92,14 @@ type (
// This flag drives `docker compose down --volumes` option
// Used only for EE
RemoveVolumes bool
// ForceRecreate is a flag indicating if the agent must force the redeployment of the stack.
// This field is only used when the Force Redeployment is triggered.
// Once the stack is redeployed, this field will be reset to false.
// For standard edge agent, this field is used in agent side
// For async edge agent, this field is used in both agent side and server side.
// This flag drives `docker compose up --force-recreate` option
ForceRecreate bool
}
// RegistryCredentials holds the credentials for a Docker registry.

View File

@@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
@@ -180,7 +181,7 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
if err != nil {
return "", err
}
defer envfile.Close()
defer logs.CloseAndLogErr(envfile)
// Copy from default .env file
defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
@@ -205,13 +206,14 @@ func copyDefaultEnvFile(w io.Writer, defaultEnvFilePath string) error {
return nil
}
defer defaultEnvFile.Close()
defer logs.CloseAndLogErr(defaultEnvFile)
if _, err = io.Copy(w, defaultEnvFile); err == nil {
if _, err = fmt.Fprintf(w, "\n"); err != nil {
return fmt.Errorf("failed to copy default env file: %w", err)
}
}
return nil
// If couldn't copy the .env file, then ignore the error and try to continue
}
@@ -223,6 +225,7 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return fmt.Errorf("failed to copy config env vars: %w", err)
}
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/testhelpers"
"github.com/stretchr/testify/require"
"github.com/rs/zerolog/log"
)
@@ -25,8 +26,11 @@ const composedContainerName = "compose_wrapper_test"
func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
dir := t.TempDir()
composeFileName := "compose_wrapper_test.yml"
f, _ := os.Create(filepath.Join(dir, composeFileName))
f.WriteString(composeFile)
f, err := os.Create(filepath.Join(dir, composeFileName))
require.NoError(t, err)
_, err = f.WriteString(composeFile)
require.NoError(t, err)
stack := &portainer.Stack{
ProjectPath: dir,
@@ -34,11 +38,7 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
Name: "project-name",
}
endpoint := &portainer.Endpoint{
URL: "unix://",
}
return stack, endpoint
return stack, &portainer.Endpoint{URL: "unix://"}
}
func Test_UpAndDown(t *testing.T) {

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