Compare commits

...

146 Commits

Author SHA1 Message Date
Ali 439714f93d fix(app): ensure placement errors surface per node [EE-7065] (#11821)
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Co-authored-by: testa113 <testa113>
2024-05-14 15:03:15 +12:00
Oscar Zhou 2745e63527 fix(image): github registry image truncated [EE-7021] (#11767) 2024-05-10 09:01:42 +12:00
Oscar Zhou 24e0318280 fix(api): list docker volume performance [EE-6896] (#11754) 2024-05-09 13:02:42 +12:00
Matt Hook 9a079a83fa fix(pending-action): pending action data format [EE-7064] (#11793)
Co-authored-by: Prabhat Khera <91852476+prabhat-portainer@users.noreply.github.com>
2024-05-09 08:15:33 +12:00
Ali 1df6087c8e fix(auth logs): fix typo in search keyword [EE-6742] (#11791)
Co-authored-by: testa113 <testa113>
2024-05-08 09:16:02 +12:00
Ali ae705bc245 fix(be-overlay): consistency overlay with variants [EE-6742] (#11775)
Co-authored-by: testa113 <testa113>
2024-05-07 16:16:52 +12:00
Ali d725b5e3b6 fix(app): show one tooltip to describe rollback feature [EE-6825] (#11778)
Co-authored-by: testa113 <testa113>
2024-05-07 15:27:25 +12:00
cmeng 1b33b1f5dd fix(container): specify node name when get a container EE-6981 (#11750) 2024-05-07 11:34:37 +12:00
Steven Kang b70f0fe3d2 fix: windows container capability [EE-5814] (#11765) 2024-05-03 10:56:38 +12:00
Ali 55ef46edb6 fix(namespace): wait for system ns setting to load before selecting existing ns [EE-6917] (#11709)
Co-authored-by: testa113 <testa113>
2024-05-02 16:43:08 +12:00
Prabhat Khera c2654d55b3 fix(images): consider stopped containers for unused label [EE-6983] (#11630) 2024-05-02 14:35:28 +12:00
Prabhat Khera 7fab352dbf chore(version-bump): bump version to 2.20.3 [EE-7063] (#11756) 2024-05-02 14:33:41 +12:00
Matt Hook 0dcb5113f7 fix(kube): correctly extract namespace from namespace manifest [EE-6555] (#11674)
Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2024-05-02 14:28:01 +12:00
Ali a1b0634d86 fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11746)
Co-authored-by: testa113 <testa113>
2024-05-02 14:23:47 +12:00
Ali da134c3e3f fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11697)
Co-authored-by: testa113 <testa113>
2024-05-02 14:22:12 +12:00
Ali 5191fc9220 fix(app): explain rollback tooltip [EE-6825] (#11699)
Co-authored-by: testa113 <testa113>
2024-05-02 14:10:40 +12:00
Ali af4e362c5c fix(version): reduce github requests [EE-7017] (#11678) 2024-05-02 14:08:44 +12:00
Matt Hook eb5b9ef069 Revert "fix(kube): fix text in activity and authentication logs teasers [EE-6742]" (#11727) 2024-05-01 09:00:13 +12:00
Matt Hook a74c6dbd24 fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11680) 2024-04-30 19:16:47 +12:00
cmeng 6451ccce94 fix(edge-stack): add completed status EE-6210 (#11633) 2024-04-30 13:44:18 +12:00
Ali 6dd5150e23 Revert "fix(app): avoid 'no label' error when deleting external app [EE-6019]" (#11696) 2024-04-26 08:51:46 +12:00
Ali 441db15cfd fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11672) 2024-04-26 08:42:13 +12:00
Chaim Lev-Ari b44fabaefe fix(users): return json from create token [EE-6856] (#11576) 2024-04-25 10:10:39 +03:00
Ali ddeddc723e fix(migration): run post init migrations for edge after server starts [EE-6905] (#11547)
Co-authored-by: testa113 <testa113>
2024-04-23 16:15:33 +12:00
Matt Hook e980ce3d6a fix(settings): fix crash during settings update when not using oauth [EE-7031] (#11660) 2024-04-23 12:58:13 +12:00
Oscar Zhou 123a138278 feat(setting/oauth): add authstyle option [EE-6038] (#11609) 2024-04-22 10:35:14 +12:00
Oscar Zhou cc3ec3cebd fix(stack/git): option to overwrite target path during dir move [EE-6871] (#11623) 2024-04-22 10:34:44 +12:00
cmeng 5dab7a1df4 fix(docker-client): explicitly set docker client scheme EE-6935 (#11518) 2024-04-22 09:00:49 +12:00
Matt Hook ed0cf4d79c chore(kubectl): update kubectl to latest point release [EE-7018] (#11621) 2024-04-19 11:47:11 +12:00
andres-portainer aa4b8ad5e3 fix(workflows): upgrade Go to v1.21.9 EE-6939 (#11643) 2024-04-18 19:03:25 -03:00
Prabhat Khera 81811f669d fix(stack): fix stack env variable link [EE-6902] (#11625) 2024-04-19 07:00:19 +12:00
andres-portainer 3ae55d8c3e fix(mingit): upgrade to v2.44.0.1 EE-7023 (#11640) 2024-04-18 15:22:25 -03:00
andres-portainer 933c2a7002 fix(docker): upgrade to v24.0.9 EE-7016 (#11619) 2024-04-17 19:38:23 -03:00
andres-portainer 1641642695 fix(go): upgrade Go to v1.21.9 in the nightly security scan EE-6939 (#11616) 2024-04-17 18:09:41 -03:00
Matt Hook f80b1ed53a fix(auth): prevent user enumeration attack [EE-6832] (#11587) 2024-04-17 16:08:56 +12:00
Prabhat Khera d04da7898d fix(pending-actions): clean pending actions for deleted environment [EE-6545] (#11599) 2024-04-17 08:32:25 +12:00
Matt Hook ec83d02afa chore(docker): bump docker client to 26.0.1 [EE-6941] (#11594) 2024-04-16 08:27:35 +12:00
Prabhat Khera 05265dda47 fix(stacks): update info text for stack environment variables [EE-6902] (#11557) 2024-04-16 08:03:46 +12:00
Prabhat Khera 74e1ff5e2d fix(pending-actions): fix create kubeclient to check endpoint status [EE-6545] (#11585) 2024-04-16 07:40:45 +12:00
Matt Hook 795d812652 chore(api): bump docker and protobuf pkgs [EE-6941] (#11549) 2024-04-15 10:52:52 +12:00
Prabhat Khera 46b1d5b528 fix(compose): update compose to 2.26.1 [EE-6546] (#11537)
* update compose to 2.24

* chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11538)

---------

Co-authored-by: hookenz <hookenz@gmail.com>
2024-04-15 10:39:28 +12:00
Matt Hook cf7672d59e bump helm version (#11563) 2024-04-15 09:18:18 +12:00
andres-portainer 9c8a30693a fix(protobuf): upgrade protobuf to v1.33 EE-6945 (#11568) 2024-04-12 17:52:52 -03:00
andres-portainer 023945cbd2 fix(go): upgrade Go to v1.21.9 EE-6939 (#11556) 2024-04-12 17:08:27 -03:00
Matt Hook 498ba46863 fix(backups): improved archive encryption [EE-6764] (#11482) 2024-04-10 10:58:16 +12:00
Matt Hook 399ddaea3b fix(services): speed up service count on the kubernetes dashboard [EE-6967] (#11524) 2024-04-09 15:50:39 +12:00
cmeng 13cee9975c feat(version): bump to 2.20.2 EE-6979 (#11517) 2024-04-08 12:27:51 +12:00
Matt Hook f8927851e4 fix(apikey): don't authenticate api key for external auth [EE-6932] (#11461) 2024-04-08 11:06:34 +12:00
Oscar Zhou b284d7094a fix(stack): filter out orphan stacks that have same name as normal stacks [EE-6791] (#11471) 2024-04-03 09:53:36 +13:00
LP B 7bb54bcbe6 fix(app): replace fields removed by Docker 25 and 26 (#11469)
* fix(app/volume): make optional Container and ContainerConfig fields removed in docker 26

* fix(app/image): use image.Size instead of image.VirtualSize removed in Docker 25
2024-03-29 13:57:18 +01:00
cmeng b3c489366f fix(edge-stack): avoid reference of undefined EE-6914 (#11465) 2024-03-27 16:02:25 +13:00
cmeng 5eca761883 feat(version): bump to 2.20.1 EE-6933 (#11459) 2024-03-27 15:41:45 +13:00
andres-portainer bea8acce1f fix(kubernetes): avoid a deadlock EE-6901 (#11446) 2024-03-25 14:19:33 -03:00
Matt Hook 6a3eda4bce fix(doclinks): fix help link paths [EE-6861] (#11417) 2024-03-19 11:46:55 +13:00
Matt Hook 889c36f64a fix(docs): fix all remaining webhook app links [EE-6861] (#11392) 2024-03-18 16:28:43 +13:00
Matt Hook c8fb3adda3 fix(kube): fix edit application webhook link [EE-6861] (#11390) 2024-03-18 10:21:20 +13:00
cmeng f15be1d92a fix(stack): prepopulate when creating template from stack EE-6853 (#11379) 2024-03-18 09:36:04 +13:00
Oscar Zhou d9ae249ffe chore(template/git): sync frontend code from ee (#11343) 2024-03-18 08:55:26 +13:00
Matt Hook 04de06c07f fix(docs): make all doc links versioned [EE-6861] (#11381) 2024-03-15 16:57:42 +13:00
Matt Hook 59d53940fe fix(stacks): update swagger stacks doc description [EE-6860] (#11383) 2024-03-15 16:47:05 +13:00
cmeng db16888379 fix(container): make blank string as valid value EE-6852 (#11372) 2024-03-15 09:01:42 +13:00
Prabhat Khera 8880876bcd fix(auth): make createAccessToken api backward compatible [EE-6818] (#11327)
* fix(auth): make createAccessToken api backward compatible [EE-6818]

* fix(api): api error message [EE-6818]

* fix messages
2024-03-14 09:02:25 +13:00
Ali bfe5a49263 fix(app): only show special message when limits change for existing app resource limit [EE-6837] (#11368)
Co-authored-by: testa113 <testa113>
2024-03-14 08:45:53 +13:00
cmeng 6e11c10bab fix(csrf): disable csrf secure cookie EE-6787 (#11299) 2024-03-13 11:22:18 +13:00
LP B cb9ab3b375 fix(app): views not loading when quickly navigating in app (#11279) 2024-03-12 15:16:19 +01:00
Chaim Lev-Ari b13dac0f6d fix(docker): apply private uac to edge admin [EE-6788] (#11284) 2024-03-12 09:59:39 +02:00
cmeng 0144a98b3b fix(edge-stack): deploy button is disabled EE-6819 (#11354) 2024-03-12 17:19:45 +13:00
Prabhat Khera 64a08c59e9 address review commets (#11361) 2024-03-12 11:32:03 +13:00
Ali 1090c82beb fix(app): on create don't mention previous values [EE-6837] (#11351)
Co-authored-by: testa113 <testa113>
2024-03-11 16:43:45 +13:00
Prabhat Khera 6094dc115b fix(container): autocomplete off for create container form [EE-6761] (#11337)
* autocomplete off doe create container form

* address review commets

* remove auto complete off from forms
2024-03-11 13:38:59 +13:00
Prabhat Khera 30513695b5 fix(kube): stackname in daemonsets and statefulsets app [EE-6670] (#11353) 2024-03-11 10:04:55 +13:00
Chaim Lev-Ari dd2be9fb1e refactor(tests): wrap tests explicitly with provider [EE-6686] (#11276) 2024-03-10 14:22:05 +02:00
Chaim Lev-Ari e265b8b67c fix(kube/config): validate change window start [EE-6830] (#11328) 2024-03-10 09:42:29 +02:00
Matt Hook cc1ce9412a fix(exec): improve alignment of help icon [EE-6816] (#11340) 2024-03-08 14:03:01 +13:00
Prabhat Khera 8eb8df2b30 fix(kube-stacks): change wordings [EE-6670] (#11335) 2024-03-08 12:15:27 +13:00
Ali c0bd2dfdaf fix(matomo): stop oauth link event [EE-6779] (#11333) 2024-03-08 10:17:26 +13:00
Matt Hook bf65a38d5a fix(exec): fix alignment and text size and alignment [EE-6816] (#11324) 2024-03-07 12:57:53 +13:00
cmeng 0ea21f2317 fix(menu): edge compute menu not clickable EE-6804 (#11320) 2024-03-07 12:11:59 +13:00
Prabhat Khera b5f839a920 fix(stacks): make stackName kube stack specific field [EE-6670] (#11316)
* fix(stacks): make stackName kube stack specific field [EE-6670]

* fix wordings
2024-03-07 11:31:28 +13:00
Prabhat Khera 29025e7dd4 fix(UI): axios progress bar loading issue [EE-6781] (#11290) 2024-03-07 11:30:23 +13:00
Ali 692981b615 fix(time window): show errors for component [EE-6800] (#11318)
Co-authored-by: testa113 <testa113>
2024-03-07 09:03:26 +13:00
Chaim Lev-Ari d6545b6af5 fix(kube/setup): add a11y labels [EE-6747] (#11308) 2024-03-06 14:57:03 +02:00
Matt Hook 6bbf62fe64 fix(contexthelp): remove extra slash from contexthelp docs link [EE-6780] (#11312) 2024-03-06 16:38:19 +13:00
Matt Hook 6b3ddf11d4 fix(helm): remove helm insights from the stack datatable [EE-6803] (#11313) 2024-03-06 16:36:48 +13:00
Dakota Walsh 77c9124e8a fix(datatable): title size EE-6774 (#11273) 2024-03-06 08:01:45 +13:00
Chaim Lev-Ari 2c3dcdd14e fix(docker/images): export image [EE-6807] (#11305) 2024-03-05 19:30:45 +02:00
matias-portainer ec913b45d6 fix(edge/templates): get correct default value for selectType env vars EE-6796 (#11293) 2024-03-04 10:35:19 -03:00
Matt Hook 51c672af21 fix(kube): update doc links to match new menu structure [EE-6759] (#11266) 2024-03-01 15:37:32 +13:00
Matt Hook ff178641be fix(help): add versioned doc links to support LTS/STS docs [EE-6780] (#11282) 2024-03-01 15:36:19 +13:00
cmeng a43454076b fix(edge-stacks): take not-found stack as removed EE-6758 (#11249) 2024-03-01 11:50:27 +13:00
cmeng a7eaa0f3fa fix(container): get old container info correctly EE-6716 (#11215) 2024-03-01 09:14:26 +13:00
cmeng 8ad11fc88f fix(stack): more space for add button EE-6773 (#11258) 2024-03-01 09:11:46 +13:00
Chaim Lev-Ari 43a95874f4 fix(auth): prevent unauthorized redirect on page load [EE-6777] (#11265) 2024-02-29 09:41:29 +02:00
Chaim Lev-Ari b4f4c3212a feat(kube): add a11y props for smoke tests [EE-6747] (#11262) 2024-02-29 09:26:10 +02:00
Chaim Lev-Ari d44f57ed6f fix(ci): prevent tests from running twice [EE-6728] (#11196) 2024-02-29 08:11:46 +02:00
Chaim Lev-Ari eba08cdca0 fix(docker): hide write buttons for non authorized [EE-6775] (#11261) 2024-02-27 12:36:47 +02:00
Prabhat Khera de3a3f88a0 fix(ui): autocomplete on edge custom template and stacks [EE-6761] (#11269) 2024-02-27 20:15:56 +13:00
Matt Hook f6b2c879bc fix(kube): make app autorefresh and show system settings stay [EE-6771] (#11256) 2024-02-27 11:18:28 +13:00
Prabhat Khera f5fbcd4d9d fix(stack): auto complete dropdown in docker stacks [EE-6761] (#11254) 2024-02-26 11:43:18 +13:00
Ali f8b68a809f fix(app): parse nan in validation check [EE-6714] (#11247) 2024-02-26 09:20:59 +13:00
Oscar Zhou 6258c02353 fix(edge/template): validate app template env vars [EE-6743] (#11234) 2024-02-26 09:00:03 +13:00
Chaim Lev-Ari 0fd20277c1 fix(docker): prevent non admins from passing security settings [EE-6765] (#11239) 2024-02-25 11:57:19 +02:00
cmeng 988064a542 fix(stack): make web editor readonly for git template EE-6706 (#11183) 2024-02-23 13:28:20 +13:00
Matt Hook 380b23a9f5 fix(dependancies): update compose and runc [EE-6744] (#11243) 2024-02-23 11:48:49 +13:00
Prabhat Khera 158b43194c fix(ui): turn autocomplete off for git deployment [EE-6761] (#11241) 2024-02-23 08:44:00 +13:00
Ali 1bbe98379a fix(app): NaN validation for autoscaling [EE-6714] (#11238) 2024-02-22 17:36:41 +13:00
Matt Hook 8f9b265f5a fix(helm) tighten up helm requests [EE-6722] (#11233) 2024-02-22 11:35:01 +13:00
Ali 1cdd3fdfe2 fix(input): allow clearing number inputs [EE-6714] (#11187) 2024-02-21 10:43:28 +13:00
Ali 4e95139909 fix(inputlist): update warning style [EE-6737] (#11222) 2024-02-21 08:29:14 +13:00
Matt Hook 704d75596d fix(libhttp): capitalize http error responses for better display [EE-6698] (#11109) 2024-02-21 07:51:29 +13:00
Chaim Lev-Ari a8938779bf fix(ui): check for authorization [EE-6733] (#11207) 2024-02-20 11:06:05 +02:00
Chaim Lev-Ari bb6f4e026a fix(kube/apps): move namespace selector in apps view [EE-6612] (#11069) 2024-02-20 10:14:11 +02:00
Ali b64166ff25 fix(app): remove insight from helm [EE-6693] (#11214)
Co-authored-by: testa113 <testa113>
2024-02-20 17:25:22 +13:00
Ali bac1c28fa9 fix(app): set values in react autoscaling form section [EE-6740] (#11220) 2024-02-20 09:35:32 +13:00
Prabhat Khera a17da6d2cd fix(git): update stack name for git stacks [EE-6670] (#11218) 2024-02-20 09:23:50 +13:00
Chaim Lev-Ari 24c2baf6cc feat(a11y): add labels and roles [EE-6717] (#11209) 2024-02-19 16:37:21 +02:00
Oscar Zhou 22b4d029fd fix(edge/template): custom template git fields not pre-filled [EE-6695] (#11113) 2024-02-19 08:39:16 +13:00
Ali b126472ec7 fix(app): update app type when changing data access policy [EE-6719] (#11210)
Co-authored-by: testa113 <testa113>
2024-02-19 08:08:17 +13:00
Ali a46fa3b2c4 fix(app): avoid duplicate env requests [EE-6727] (#11193)
Co-authored-by: testa113 <testa113>
2024-02-16 14:02:02 +13:00
Prabhat Khera a374157d6f fix(ui): update search placeholder [EE-6667] (#11191)
* update search placeholder

* remove box selector description
2024-02-16 12:34:10 +13:00
Matt Hook 861ed662e2 fix(namespace): fix default namespace quota [EE-6700] (#11184) 2024-02-16 08:17:10 +13:00
Chaim Lev-Ari 99b89a8ec5 chore(eslint): add rule to check imports [EE-6730] (#11200) 2024-02-15 17:45:54 +02:00
Chaim Lev-Ari 95750c2339 fix(auth): export hasAuthorizations [EE-6595] (#11198) 2024-02-15 14:05:45 +02:00
Chaim Lev-Ari 165d6165dc feat(ui): restrict views by role [EE-6595] (#11071) 2024-02-15 13:29:55 +02:00
Chaim Lev-Ari fe6ed55cab feat(edge/stacks): add app templates to deploy types [EE-6632] (#11070) 2024-02-15 09:00:57 +02:00
Chaim Lev-Ari edea9e3481 feat(auth): add useIsEdgeAdmin hook [EE-6627] (#11101) 2024-02-14 19:50:26 -03:00
Ali c08b5af85a fix(insight): split insight from input [EE-6693] (#11177)
Co-authored-by: testa113 <testa113>
2024-02-15 10:46:02 +13:00
Prabhat Khera ed861044a7 Revert "fix(logs): add NOCOLOR option for use when exporting to greylog etc […" (#11178)
This reverts commit aca6d33548.
2024-02-15 06:26:22 +13:00
Chaim Lev-Ari a83321ebe6 feat(ui): write tests [EE-6685] (#11082) 2024-02-14 17:25:32 +02:00
Ali 513cd9c9b3 fix(configs): correct 'external' display in tables [EE-6649] (#11111)
Co-authored-by: testa113 <testa113>
2024-02-14 11:48:05 +13:00
Ali dc94bf141e fix(stacks): add app form stacks input [EE-6693] (#11105) 2024-02-14 09:01:02 +13:00
Dakota Walsh 24471a9ae1 fix(restore): add S3 teaser [EE-6675] (#11096) 2024-02-14 08:40:34 +13:00
Matt Hook aca6d33548 fix(logs): add NOCOLOR option for use when exporting to greylog etc [EE-6696] (#11107) 2024-02-14 07:54:47 +13:00
Ali ca77b85c65 fix(kube-owner): owner labels from resources created via manifest [EE-6647] (#11103)
Co-authored-by: testa113 <testa113>
2024-02-12 15:30:59 +13:00
Prabhat Khera 1fd4291630 fix(ui): stackname auto fill on create from manifest screen [EE-6688] (#11100)
* fix(ui): stackname auto fill on create from manifest screen [EE-6688]

* address review comment
2024-02-12 10:54:24 +13:00
Ali 08dd7f6d2a fix(auth): isAdmin redirect for wizard [EE-6669] (#11075)
Co-authored-by: testa113 <testa113>
2024-02-12 08:04:44 +13:00
Prabhat Khera ce4b0e759c fix(ui): scroll issue [EE-6667 (#11085)
* Fix scroll issue

* fix minorissue

* address review comments

* add comment
2024-02-09 15:35:38 +13:00
Steven Kang 538e7a823b fix: pre-release build only after merging (#11098) 2024-02-09 15:26:39 +13:00
Matt Hook 956e8d3c59 fix(docs): fix swagger docs for webhook params [EE-6668] (#11089) 2024-02-09 14:44:29 +13:00
Prabhat Khera 1c5458f0d4 fix(kube): ingress path duplication issue [EE-6649] (#11087) 2024-02-09 07:49:57 +13:00
Prabhat Khera f6085ffad7 fix stack name update issue (#11065) 2024-02-08 13:51:06 +13:00
Matt Hook 490bda2eaf fix(kube-apps): add helm insights, remove namespace insights panel [EE-6671] (#11078) 2024-02-08 11:18:48 +13:00
Prabhat Khera d601d8eb7b fix(UI): some minor fixes [EE-6667] (#11062)
* minor tweeks for kubernetes settings

* address review comments
2024-02-06 12:17:35 +13:00
Steven Kang b0564b9238 Pre-release as part of the CI (#11067)
* feat: add pre-release
* feat: add extension
* feat: fix typo
2024-02-05 18:29:12 +13:00
Prabhat Khera 8922585a70 keep labels on edit ingress, configmaps and secrets (#11063) 2024-02-05 16:30:31 +13:00
Ali d7cf2284dc fix(r2a): don't set errors to undefined [EE-6665] (#11060)
Co-authored-by: testa113 <testa113>
2024-02-05 14:24:15 +13:00
439 changed files with 6480 additions and 2366 deletions
+21
View File
@@ -10,6 +10,7 @@ globals:
extends: extends:
- 'eslint:recommended' - 'eslint:recommended'
- 'plugin:storybook/recommended' - 'plugin:storybook/recommended'
- 'plugin:import/typescript'
- prettier - prettier
plugins: plugins:
@@ -29,6 +30,7 @@ rules:
no-empty: warn no-empty: warn
no-empty-function: warn no-empty-function: warn
no-useless-escape: 'off' no-useless-escape: 'off'
import/named: error
import/order: import/order:
[ [
'error', 'error',
@@ -43,6 +45,12 @@ rules:
pathGroupsExcludedImportTypes: ['internal'], pathGroupsExcludedImportTypes: ['internal'],
}, },
] ]
no-restricted-imports:
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
settings: settings:
'import/resolver': 'import/resolver':
@@ -51,6 +59,8 @@ settings:
- ['@@', './app/react/components'] - ['@@', './app/react/components']
- ['@', './app'] - ['@', './app']
extensions: ['.js', '.ts', '.tsx'] extensions: ['.js', '.ts', '.tsx']
typescript: true
node: true
overrides: overrides:
- files: - files:
@@ -75,6 +85,7 @@ overrides:
settings: settings:
react: react:
version: 'detect' version: 'detect'
rules: rules:
import/order: import/order:
[ [
@@ -108,6 +119,12 @@ overrides:
'no-await-in-loop': 'off' 'no-await-in-loop': 'off'
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }] 'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]] 'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
'@typescript-eslint/no-restricted-imports':
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
overrides: # allow props spreading for hoc files overrides: # allow props spreading for hoc files
- files: - files:
- app/**/with*.ts{,x} - app/**/with*.ts{,x}
@@ -121,7 +138,11 @@ overrides:
'vitest/env': true 'vitest/env': true
rules: rules:
'react/jsx-no-constructed-context-values': off 'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
- files: - files:
- app/**/*.stories.* - app/**/*.stories.*
rules: rules:
'no-alert': off 'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
+72 -53
View File
@@ -5,7 +5,7 @@ on:
push: push:
branches: branches:
- 'develop' - 'develop'
- '!release/*' - 'release/*'
pull_request: pull_request:
branches: branches:
- 'develop' - 'develop'
@@ -20,9 +20,9 @@ on:
- ready_for_review - ready_for_review
env: env:
DOCKER_HUB_REPO: portainerci/portainer DOCKER_HUB_REPO: portainerci/portainer-ce
NODE_ENV: testing EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
NODE_VERSION: 18.x NODE_VERSION: 18.x
jobs: jobs:
@@ -30,81 +30,59 @@ jobs:
strategy: strategy:
matrix: matrix:
config: config:
- { platform: linux, arch: amd64 } - { platform: linux, arch: amd64, version: "" }
- { platform: linux, arch: arm64 } - { platform: linux, arch: arm64, version: "" }
- { platform: linux, arch: arm, version: "" }
- { platform: linux, arch: ppc64le, version: "" }
- { platform: linux, arch: s390x, version: "" }
- { platform: windows, arch: amd64, version: 1809 } - { platform: windows, arch: amd64, version: 1809 }
- { platform: windows, arch: amd64, version: ltsc2022 } - { platform: windows, arch: amd64, version: ltsc2022 }
runs-on: arc-runner-set runs-on: ubuntu-latest
if: github.event.pull_request.draft == false if: github.event.pull_request.draft == false
steps: steps:
- name: '[preparation] checkout the current branch' - name: '[preparation] checkout the current branch'
uses: actions/checkout@v3.5.3 uses: actions/checkout@v4.1.1
with: with:
ref: ${{ github.event.inputs.branch }} ref: ${{ github.event.inputs.branch }}
- name: '[preparation] set up golang' - name: '[preparation] set up golang'
uses: actions/setup-go@v4.0.1 uses: actions/setup-go@v5.0.0
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
cache: false
- name: '[preparation] cache paths'
id: cache-dir-path
run: |
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: '[preparation] cache go'
uses: actions/cache@v3
with:
path: |
${{ steps.cache-dir-path.outputs.go-build-dir }}
${{ steps.cache-dir-path.outputs.go-mod-dir }}
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
enableCrossOsArchive: true
- name: '[preparation] set up node.js' - name: '[preparation] set up node.js'
uses: actions/setup-node@v3 uses: actions/setup-node@v4.0.1
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: '' cache: 'yarn'
- name: '[preparation] cache yarn'
uses: actions/cache@v3
with:
path: |
**/node_modules
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
enableCrossOsArchive: true
- name: '[preparation] set up qemu' - name: '[preparation] set up qemu'
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3.0.0
- name: '[preparation] set up docker context for buildx' - name: '[preparation] set up docker context for buildx'
run: docker context create builders run: docker context create builders
- name: '[preparation] set up docker buildx' - name: '[preparation] set up docker buildx'
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3.0.0
with: with:
endpoint: builders endpoint: builders
- name: '[preparation] docker login' - name: '[preparation] docker login'
uses: docker/login-action@v2.2.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: '[preparation] set the container image tag' - name: '[preparation] set the container image tag'
run: | run: |
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
# use the release branch name as the tag for release branches
# for instance, release/2.19 becomes 2.19
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
# use pr${{ github.event.number }} as the tag for pull requests
# for instance, pr123
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}" CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
else else
# replace / with - in the branch name
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g') CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi fi
if [ "${{ matrix.config.platform }}" == "windows" ]; then echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
else
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
fi
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
- name: '[execution] build linux & windows portainer binaries' - name: '[execution] build linux & windows portainer binaries'
run: | run: |
export YARN_VERSION=$(yarn --version) export YARN_VERSION=$(yarn --version)
@@ -112,6 +90,12 @@ jobs:
export BUILDNUMBER=${GITHUB_RUN_NUMBER} export BUILDNUMBER=${GITHUB_RUN_NUMBER}
GIT_COMMIT_HASH_LONG=${{ github.sha }} GIT_COMMIT_HASH_LONG=${{ github.sha }}
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7} export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
NODE_ENV="testing"
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
NODE_ENV="production"
fi
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV} make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
env: env:
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }} CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
@@ -123,35 +107,70 @@ jobs:
else else
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile . docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile . docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
fi
fi fi
env: env:
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }} CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
build_manifests: build_manifests:
runs-on: arc-runner-set runs-on: ubuntu-latest
if: github.event.pull_request.draft == false if: github.event.pull_request.draft == false
needs: [build_images] needs: [build_images]
steps: steps:
- name: '[preparation] docker login' - name: '[preparation] docker login'
uses: docker/login-action@v2.2.0 uses: docker/login-action@v3.0.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: '[preparation] set up docker context for buildx' - name: '[preparation] set up docker context for buildx'
run: docker version && docker context create builders run: docker version && docker context create builders
- name: '[preparation] set up docker buildx' - name: '[preparation] set up docker buildx'
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3.0.0
with: with:
endpoint: builders endpoint: builders
- name: '[execution] build and push manifests' - name: '[execution] build and push manifests'
run: | run: |
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
# use the release branch name as the tag for release branches
# for instance, release/2.19 becomes 2.19
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
# use pr${{ github.event.number }} as the tag for pull requests
# for instance, pr123
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}" CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
else else
# replace / with - in the branch name
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g') CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi fi
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \ docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \ "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \ "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \ "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64" "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
fi
+1 -1
View File
@@ -18,7 +18,7 @@ on:
- ready_for_review - ready_for_review
env: env:
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
NODE_VERSION: 18.x NODE_VERSION: 18.x
jobs: jobs:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
jobs: jobs:
client-dependencies: client-dependencies:
+1 -1
View File
@@ -14,7 +14,7 @@ on:
- '.github/workflows/pr-security.yml' - '.github/workflows/pr-security.yml'
env: env:
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
NODE_VERSION: 18.x NODE_VERSION: 18.x
jobs: jobs:
+9 -1
View File
@@ -1,17 +1,25 @@
name: Test name: Test
env: env:
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
NODE_VERSION: 18.x NODE_VERSION: 18.x
on: on:
pull_request: pull_request:
branches:
- master
- develop
- release/*
types: types:
- opened - opened
- reopened - reopened
- synchronize - synchronize
- ready_for_review - ready_for_review
push: push:
branches:
- master
- develop
- release/*
jobs: jobs:
test-client: test-client:
+1 -1
View File
@@ -13,7 +13,7 @@ on:
- ready_for_review - ready_for_review
env: env:
GO_VERSION: 1.21.6 GO_VERSION: 1.21.9
NODE_VERSION: 18.x NODE_VERSION: 18.x
jobs: jobs:
-1
View File
@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
initMSW( initMSW(
{ {
onUnhandledRequest: ({ method, url }) => { onUnhandledRequest: ({ method, url }) => {
console.log(method, url);
if (url.startsWith('/api')) { if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}. console.error(`Unhandled ${method} request to ${url}.
+1 -1
View File
@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
if password != "" { if password != "" {
archive, err = decrypt(archive, password) archive, err = decrypt(archive, password)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to decrypt the archive") return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
} }
} }
+16 -10
View File
@@ -19,6 +19,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator" "github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/datastore/postinit"
"github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client" dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -457,19 +458,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
authorizationService := authorization.NewService(dataStore) authorizationService := authorization.NewService(dataStore)
authorizationService.K8sClientFactory = kubernetesClientFactory authorizationService.K8sClientFactory = kubernetesClientFactory
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath) kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService) proxyManager := proxy.NewManager(kubernetesClientFactory)
reverseTunnelService.ProxyManager = proxyManager reverseTunnelService.ProxyManager = proxyManager
@@ -489,6 +482,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets) kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, dockerClientFactory, authorizationService, shutdownCtx, *flags.Assets, kubernetesDeployer)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
proxyManager.NewProxyFactory(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
helmPackageManager, err := initHelmPackageManager(*flags.Assets) helmPackageManager, err := initHelmPackageManager(*flags.Assets)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager") log.Fatal().Err(err).Msg("failed initializing helm package manager")
@@ -578,10 +581,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
// but some more complex migrations require access to a kubernetes or docker // but some more complex migrations require access to a kubernetes or docker
// client. Therefore we run a separate migration process just before // client. Therefore we run a separate migration process just before
// starting the server. // starting the server.
postInitMigrator := datastore.NewPostInitMigrator( postInitMigrator := postinit.NewPostInitMigrator(
kubernetesClientFactory, kubernetesClientFactory,
dockerClientFactory, dockerClientFactory,
dataStore, dataStore,
*flags.Assets,
kubernetesDeployer,
) )
if err := postInitMigrator.PostInitMigrate(); err != nil { if err := postInitMigrator.PostInitMigrate(); err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations") log.Fatal().Err(err).Msg("failure during post init migrations")
@@ -650,6 +655,7 @@ func main() {
Msg("starting Portainer") Msg("starting Portainer")
err := server.Start() err := server.Start()
log.Info().Err(err).Msg("HTTP server exited") log.Info().Err(err).Msg("HTTP server exited")
} }
} }
+194 -32
View File
@@ -1,52 +1,216 @@
package crypto package crypto
import ( import (
"bufio"
"bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io" "io"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/scrypt" "golang.org/x/crypto/scrypt"
) )
// NOTE: has to go with what is considered to be a simplistic in that it omits any const (
// authentication of the encrypted data. // AES GCM settings
// Person with better knowledge is welcomed to improve it. aesGcmHeader = "AES256-GCM" // The encrypted file header
// sourced from https://golang.org/src/crypto/cipher/example_test.go aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
var emptySalt []byte = make([]byte, 0) // Argon2 settings
// Recommded settings lower memory hardware according to current OWASP recommendations
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
argon2MemoryCost = 12 * 1024
argon2TimeCost = 3
argon2Threads = 1
argon2KeyLength = 32
)
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output. // AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
// passphrase is used to generate an encryption key.
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
// making a 32 bytes key that would correspond to AES-256 err := aesEncryptGCM(input, output, passphrase)
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
if err != nil { if err != nil {
return err return fmt.Errorf("error encrypting file: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
writer := &cipher.StreamWriter{S: stream, W: output}
// Copy the input to the output, encrypting as we go.
if _, err := io.Copy(writer, input); err != nil {
return err
} }
return nil return nil
} }
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from. // AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
// passphrase is used to generate an encryption key.
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) { func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// Read file header to determine how it was encrypted
inputReader := bufio.NewReader(input)
header, err := inputReader.Peek(len(aesGcmHeader))
if err != nil {
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
}
if string(header) == aesGcmHeader {
reader, err := aesDecryptGCM(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting file: %w", err)
}
return reader, nil
}
// Use the previous decryption routine which has no header (to support older archives)
reader, err := aesDecryptOFB(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
}
return reader, nil
}
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
// Derive key using argon2 with a random salt
salt := make([]byte, 16) // 16 bytes salt
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
// Generate nonce
nonce, err := NewRandomNonce(aesgcm.NonceSize())
if err != nil {
return err
}
// write the header
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
return err
}
// Write nonce and salt to the output file
if _, err := output.Write(salt); err != nil {
return err
}
if _, err := output.Write(nonce.Value()); err != nil {
return err
}
// Buffer for reading plaintext blocks
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
// Encrypt plaintext in blocks
for {
n, err := io.ReadFull(input, buf)
if n == 0 {
break // end of plaintext input
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return err
}
// Seal encrypts the plaintext using the nonce returning the updated slice.
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
_, err = output.Write(ciphertext)
if err != nil {
return err
}
nonce.Increment()
}
return nil
}
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
// Reader & verify header
header := make([]byte, len(aesGcmHeader))
if _, err := io.ReadFull(input, header); err != nil {
return nil, err
}
if string(header) != aesGcmHeader {
return nil, fmt.Errorf("invalid header")
}
// Read salt
salt := make([]byte, 16) // Salt size
if _, err := io.ReadFull(input, salt); err != nil {
return nil, err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
// Initialize AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create GCM mode with the cipher block
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Read nonce from the input reader
nonce := NewNonce(aesgcm.NonceSize())
if err := nonce.Read(input); err != nil {
return nil, err
}
// Initialize a buffer to store decrypted data
buf := bytes.Buffer{}
plaintext := make([]byte, aesGcmBlockSize)
// Decrypt the ciphertext in blocks
for {
// Read a block of ciphertext from the input reader
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
n, err := io.ReadFull(input, ciphertextBlock)
if n == 0 {
break // end of ciphertext
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return nil, err
}
// Decrypt the block of ciphertext
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
if err != nil {
return nil, err
}
_, err = buf.Write(plaintext)
if err != nil {
return nil, err
}
nonce.Increment()
}
return &buf, nil
}
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// passphrase is used to generate an encryption key.
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
var emptySalt []byte = make([]byte, 0)
// making a 32 bytes key that would correspond to AES-256 // making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty // don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32) key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
@@ -59,11 +223,9 @@ func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err return nil, err
} }
// If the key is unique for each ciphertext, then it's ok to use a zero // If the key is unique for each ciphertext, then it's ok to use a zero IV.
// IV.
var iv [aes.BlockSize]byte var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:]) stream := cipher.NewOFB(block, iv[:])
reader := &cipher.StreamReader{S: stream, R: input} reader := &cipher.StreamReader{S: stream, R: input}
return reader, nil return reader, nil
+101 -11
View File
@@ -2,6 +2,7 @@ package crypto
import ( import (
"io" "io"
"math/rand"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -9,7 +10,19 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return b
}
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
const passphrase = "passphrase"
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@@ -18,17 +31,99 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted") decryptedFilePath = filepath.Join(tmpdir, "decrypted")
) )
content := []byte("content") content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600) os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath) originFile, _ := os.Open(originFilePath)
defer originFile.Close() defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file") assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath) encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file") assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
@@ -57,7 +152,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted") decryptedFilePath = filepath.Join(tmpdir, "decrypted")
) )
content := []byte("content") content := randBytes(1024 * 50)
os.WriteFile(originFilePath, content, 0600) os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath) originFile, _ := os.Open(originFilePath)
@@ -96,7 +191,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFilePath = filepath.Join(tmpdir, "decrypted") decryptedFilePath = filepath.Join(tmpdir, "decrypted")
) )
content := []byte("content") content := randBytes(1034)
os.WriteFile(originFilePath, content, 0600) os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath) originFile, _ := os.Open(originFilePath)
@@ -117,11 +212,6 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage")) _, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase") assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
} }
+61
View File
@@ -0,0 +1,61 @@
package crypto
import (
"crypto/rand"
"errors"
"io"
)
type Nonce struct {
val []byte
}
func NewNonce(size int) *Nonce {
return &Nonce{val: make([]byte, size)}
}
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
// This ensures there are plenty of nonce values availble before rolling over
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
func NewRandomNonce(size int) (*Nonce, error) {
randomBytes := 1
if size <= randomBytes {
return nil, errors.New("nonce size must be greater than the number of random bytes")
}
randomPart := make([]byte, randomBytes)
if _, err := rand.Read(randomPart); err != nil {
return nil, err
}
zeroPart := make([]byte, size-randomBytes)
nonceVal := append(randomPart, zeroPart...)
return &Nonce{val: nonceVal}, nil
}
func (n *Nonce) Read(stream io.Reader) error {
_, err := io.ReadFull(stream, n.val)
return err
}
func (n *Nonce) Value() []byte {
return n.val
}
func (n *Nonce) Increment() error {
// Start incrementing from the least significant byte
for i := len(n.val) - 1; i >= 0; i-- {
// Increment the current byte
n.val[i]++
// Check for overflow
if n.val[i] != 0 {
// No overflow, nonce is successfully incremented
return nil
}
}
// If we reach here, it means the nonce has overflowed
return errors.New("nonce overflow")
}
+1
View File
@@ -73,6 +73,7 @@ type (
PendingActionsService interface { PendingActionsService interface {
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID] BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
GetNextIdentifier() int GetNextIdentifier() int
DeleteByEndpointID(ID portainer.EndpointID) error
} }
// EdgeStackService represents a service to manage Edge stacks // EdgeStackService represents a service to manage Edge stacks
@@ -1,10 +1,12 @@
package pendingactions package pendingactions
import ( import (
"fmt"
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
) )
const ( const (
@@ -45,6 +47,12 @@ func (s Service) Update(ID portainer.PendingActionsID, config *portainer.Pending
}) })
} }
func (s Service) DeleteByEndpointID(ID portainer.EndpointID) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).DeleteByEndpointID(ID)
})
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx { func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{ return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]{ BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]{
@@ -68,6 +76,29 @@ func (s ServiceTx) Update(ID portainer.PendingActionsID, config *portainer.Pendi
return s.BaseDataServiceTx.Update(ID, config) return s.BaseDataServiceTx.Update(ID, config)
} }
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
pendingActions, err := s.BaseDataServiceTx.ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
}
for _, pendingAction := range pendingActions {
if pendingAction.EndpointID == ID {
err := s.BaseDataServiceTx.Delete(pendingAction.ID)
if err != nil {
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
}
}
}
return nil
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service ServiceTx) GetNextIdentifier() int {
return service.Tx.GetNextIdentifier(BucketName)
}
// GetNextIdentifier returns the next identifier for a custom template. // GetNextIdentifier returns the next identifier for a custom template.
func (service *Service) GetNextIdentifier() int { func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName) return service.Connection.GetNextIdentifier(BucketName)
+1
View File
@@ -86,6 +86,7 @@ func (store *Store) newMigratorParameters(version *models.Version) *migrator.Mig
EdgeStackService: store.EdgeStackService, EdgeStackService: store.EdgeStackService,
EdgeJobService: store.EdgeJobService, EdgeJobService: store.EdgeJobService,
TunnelServerService: store.TunnelServerService, TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
} }
} }
-117
View File
@@ -1,117 +0,0 @@
package datastore
import (
"context"
"github.com/docker/docker/api/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerclient.ClientFactory
dataStore dataservices.DataStore
}
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
}
}
func (migrator *PostInitMigrator) PostInitMigrate() error {
if err := migrator.PostInitMigrateIngresses(); err != nil {
return err
}
migrator.PostInitMigrateGPUs()
return nil
}
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if !endpoints[i].PostInitMigrations.MigrateIngresses {
return nil
}
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
environments, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Err(err).Msg("failure getting endpoints")
return
}
for i := range environments {
if environments[i].Type == portainer.DockerEnvironment {
// // Early exit if we do not need to migrate!
if !environments[i].PostInitMigrations.MigrateGPUs {
return
}
// set the MigrateGPUs flag to false so we don't run this again
environments[i].PostInitMigrations.MigrateGPUs = false
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
// create a docker client
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
if err != nil {
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
return
}
defer dockerClient.Close()
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
log.Err(err).Msg("failed to list containers")
return
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
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.Err(err).Msg("failed to inspect container")
return
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environments[i].EnableGPUManagement = true
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
break containersLoop
}
}
}
}
}
}
@@ -23,3 +23,29 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
return migrator.settingsService.UpdateSettings(settings) return migrator.settingsService.UpdateSettings(settings)
} }
// In PortainerCE the resource overcommit option should always be true across all endpoints
func (migrator *Migrator) updateResourceOverCommitToDB110() error {
log.Info().Msg("updating resource overcommit setting to true")
endpoints, err := migrator.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.KubernetesLocalEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true
err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
}
return nil
}
@@ -0,0 +1,32 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
func (migrator *Migrator) cleanPendingActionsForDeletedEndpointsForDB111() error {
log.Info().Msg("cleaning up pending actions for deleted endpoints")
pendingActions, err := migrator.pendingActionsService.ReadAll()
if err != nil {
return err
}
endpoints := make(map[portainer.EndpointID]struct{})
for _, action := range pendingActions {
endpoints[action.EndpointID] = struct{}{}
}
for endpointId := range endpoints {
_, err := migrator.endpointService.Endpoint(endpointId)
if dataservices.IsErrObjectNotFound(err) {
err := migrator.pendingActionsService.DeleteByEndpointID(endpointId)
if err != nil {
return err
}
}
}
return nil
}
+8
View File
@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
"github.com/portainer/portainer/api/dataservices/extension" "github.com/portainer/portainer/api/dataservices/extension"
"github.com/portainer/portainer/api/dataservices/fdoprofile" "github.com/portainer/portainer/api/dataservices/fdoprofile"
"github.com/portainer/portainer/api/dataservices/pendingactions"
"github.com/portainer/portainer/api/dataservices/registry" "github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/dataservices/resourcecontrol" "github.com/portainer/portainer/api/dataservices/resourcecontrol"
"github.com/portainer/portainer/api/dataservices/role" "github.com/portainer/portainer/api/dataservices/role"
@@ -58,6 +59,7 @@ type (
edgeStackService *edgestack.Service edgeStackService *edgestack.Service
edgeJobService *edgejob.Service edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
} }
// MigratorParameters represents the required parameters to create a new Migrator instance. // MigratorParameters represents the required parameters to create a new Migrator instance.
@@ -85,6 +87,7 @@ type (
EdgeStackService *edgestack.Service EdgeStackService *edgestack.Service
EdgeJobService *edgejob.Service EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
} }
) )
@@ -114,6 +117,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
edgeStackService: parameters.EdgeStackService, edgeStackService: parameters.EdgeStackService,
edgeJobService: parameters.EdgeJobService, edgeJobService: parameters.EdgeJobService,
TunnelServerService: parameters.TunnelServerService, TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
} }
migrator.initMigrations() migrator.initMigrations()
@@ -230,6 +234,10 @@ func (m *Migrator) initMigrations() {
) )
m.addMigrations("2.20", m.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110, m.updateAppTemplatesVersionForDB110,
m.updateResourceOverCommitToDB110,
)
m.addMigrations("2.20.2",
m.cleanPendingActionsForDeletedEndpointsForDB111,
) )
// Add new migrations below... // Add new migrations below...
+95
View File
@@ -0,0 +1,95 @@
package datastore
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/pendingactions/actions"
)
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
defer store.Close()
testData := []struct {
Name string
PendingAction portainer.PendingActions
Expected *actions.CleanNAPWithOverridePoliciesPayload
Err bool
}{
{
Name: "test actiondata with EndpointGroupID 1",
PendingAction: portainer.PendingActions{
EndpointID: 1,
Action: "CleanNAPWithOverridePolicies",
ActionData: &actions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: 1,
},
},
Expected: &actions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: 1,
},
},
{
Name: "test actionData nil",
PendingAction: portainer.PendingActions{
EndpointID: 2,
Action: "CleanNAPWithOverridePolicies",
ActionData: nil,
},
Expected: nil,
},
{
Name: "test actionData empty and expected error",
PendingAction: portainer.PendingActions{
EndpointID: 2,
Action: "CleanNAPWithOverridePolicies",
ActionData: "",
},
Expected: nil,
Err: true,
},
}
for _, d := range testData {
err := store.PendingActions().Create(&d.PendingAction)
if err != nil {
t.Error(err)
return
}
pendingActions, err := store.PendingActions().ReadAll()
if err != nil {
t.Error(err)
return
}
for _, endpointPendingAction := range pendingActions {
t.Run(d.Name, func(t *testing.T) {
if endpointPendingAction.Action == "CleanNAPWithOverridePolicies" {
actionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(endpointPendingAction.ActionData)
if d.Err && err == nil {
t.Error(err)
}
if d.Expected == nil && actionData != nil {
t.Errorf("expected nil , got %d", actionData)
}
if d.Expected != nil && actionData == nil {
t.Errorf("expected not nil , got %d", actionData)
}
if d.Expected != nil && actionData.EndpointGroupID != d.Expected.EndpointGroupID {
t.Errorf("expected EndpointGroupID %d , got %d", d.Expected.EndpointGroupID, actionData.EndpointGroupID)
}
}
})
}
store.PendingActions().Delete(d.PendingAction.ID)
}
})
}
+203
View File
@@ -0,0 +1,203 @@
package postinit
import (
"context"
"fmt"
"reflect"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerClient.ClientFactory
dataStore dataservices.DataStore
assetsPath string
kubernetesDeployer portainer.KubernetesDeployer
}
func NewPostInitMigrator(
kubeFactory *cli.ClientFactory,
dockerFactory *dockerClient.ClientFactory,
dataStore dataservices.DataStore,
assetsPath string,
kubernetesDeployer portainer.KubernetesDeployer,
) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
assetsPath: assetsPath,
kubernetesDeployer: kubernetesDeployer,
}
}
// 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
}
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpointutils.IsEdgeEndpoint(&environment) {
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
}
} else {
// non-edge environments will run before the server starts.
err = postInitMigrator.MigrateEnvironment(&environment)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
}
}
}
return nil
}
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
// TODO: This should be moved into pending actions as part of the pending action migration
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
migrateEnvPendingAction := portainer.PendingActions{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
}
// Get all pending actions and filter them by endpoint, action and action args that are equal to the migrateEnvPendingAction
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
if err != nil {
log.Error().Err(err).Msgf("Error retrieving pending actions")
return fmt.Errorf("failed to retrieve pending actions for environment %d: %w", environmentID, err)
}
for _, pendingAction := range pendingActions {
if pendingAction.EndpointID == environmentID &&
pendingAction.Action == migrateEnvPendingAction.Action &&
reflect.DeepEqual(pendingAction.ActionData, migrateEnvPendingAction.ActionData) {
log.Debug().Msgf("Migration pending action for environment %d already exists, skipping creating another", environmentID)
return nil
}
}
// If there are no pending actions for the given endpoint, create one
err = postInitMigrator.dataStore.PendingActions().Create(&migrateEnvPendingAction)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
}
return nil
}
// 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)
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.GetKubeClient(environment)
if err != nil {
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
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 {
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)
return err
}
defer dockerClient.Close()
migrator.MigrateGPUs(*environment, dockerClient)
}
return nil
}
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateIngresses {
return nil
}
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
if err != nil {
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
return err
}
return nil
}
// MigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient *client.Client) error {
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", environment.ID)
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
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)
return err
}
// 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
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environment.EnableGPUManagement = true
break containersLoop
}
}
}
// set the MigrateGPUs flag to false so we don't run this again
environment.PostInitMigrations.MigrateGPUs = false
err = tx.Endpoint().UpdateEndpoint(environment.ID, environment)
if err != nil {
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
return err
}
return nil
})
}
+3 -1
View File
@@ -16,7 +16,9 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil } func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil }
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService { return nil } func (tx *StoreTx) PendingActions() dataservices.PendingActionsService {
return tx.store.PendingActionsService.Tx(tx.tx)
}
func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService { func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService {
return tx.store.EdgeGroupService.Tx(tx.tx) return tx.store.EdgeGroupService.Tx(tx.tx)
@@ -631,6 +631,7 @@
"LogoURL": "", "LogoURL": "",
"OAuthSettings": { "OAuthSettings": {
"AccessTokenURI": "", "AccessTokenURI": "",
"AuthStyle": 0,
"AuthorizationURI": "", "AuthorizationURI": "",
"ClientID": "", "ClientID": "",
"DefaultTeamID": 0, "DefaultTeamID": 0,
@@ -677,6 +678,7 @@
"Architecture": "", "Architecture": "",
"BridgeNfIp6tables": false, "BridgeNfIp6tables": false,
"BridgeNfIptables": false, "BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false, "CPUSet": false,
"CPUShares": false, "CPUShares": false,
"CgroupDriver": "", "CgroupDriver": "",
@@ -939,6 +941,6 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
} }
} }
+10 -4
View File
@@ -13,7 +13,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
) )
@@ -93,11 +93,17 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return nil, err return nil, err
} }
return client.NewClientWithOpts( opts := []client.Opt{
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
) }
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
return client.NewClientWithOpts(opts...)
} }
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) { func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
@@ -159,7 +165,7 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
var rs []struct { var rs []struct {
types.ImageSummary image.Summary
Portainer struct { Portainer struct {
Agent struct { Agent struct {
NodeName string NodeName string
+4 -4
View File
@@ -119,7 +119,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
for _, network := range container.NetworkSettings.Networks { for _, network := range container.NetworkSettings.Networks {
cli.NetworkConnect(ctx, network.NetworkID, containerId, network) cli.NetworkConnect(ctx, network.NetworkID, containerId, network)
} }
cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{}) cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{})
}) })
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container") log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
@@ -135,7 +135,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
c.sr.push(func() { c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container") log.Debug().Str("container_id", create.ID).Msg("removing the new container")
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}) cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{}) cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
}) })
if err != nil { if err != nil {
@@ -164,14 +164,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// 8. start the new container // 8. start the new container
log.Debug().Str("container_id", newContainerId).Msg("starting the new container") log.Debug().Str("container_id", newContainerId).Msg("starting the new container")
err = cli.ContainerStart(ctx, newContainerId, types.ContainerStartOptions{}) err = cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{})
if err != nil { if err != nil {
return nil, errors.Wrap(err, "start container error") return nil, errors.Wrap(err, "start container error")
} }
// 9. delete the old container // 9. delete the old container
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container") log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
_ = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{}) _ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
c.sr.disable() c.sr.disable()
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
consts "github.com/portainer/portainer/api/docker/consts" consts "github.com/portainer/portainer/api/docker/consts"
@@ -157,7 +158,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
return Error, nil return Error, nil
} }
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ containers, err := cli.ContainerList(ctx, container.ListOptions{
All: true, All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)), Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)),
}) })
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
_container "github.com/docker/docker/api/types/container" _container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@@ -147,7 +148,7 @@ func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Clien
} }
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error { func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true}) containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil { if err != nil {
return err return err
} }
+10 -4
View File
@@ -934,7 +934,7 @@ func FileExists(filePath string) (bool, error) {
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error { func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
// 1. Backup the source directory to a different folder // 1. Backup the source directory to a different folder
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup") backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
err := MoveDirectory(originalPath, backupDir) err := MoveDirectory(originalPath, backupDir, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to backup source directory: %w", err) return fmt.Errorf("failed to backup source directory: %w", err)
} }
@@ -973,14 +973,14 @@ func restoreBackup(src, backupDir string) error {
return fmt.Errorf("failed to delete destination directory: %w", err) return fmt.Errorf("failed to delete destination directory: %w", err)
} }
err = MoveDirectory(backupDir, src) err = MoveDirectory(backupDir, src, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to restore backup directory: %w", err) return fmt.Errorf("failed to restore backup directory: %w", err)
} }
return nil return nil
} }
func MoveDirectory(originalPath, newPath string) error { func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error {
if _, err := os.Stat(originalPath); err != nil { if _, err := os.Stat(originalPath); err != nil {
return err return err
} }
@@ -991,7 +991,13 @@ func MoveDirectory(originalPath, newPath string) error {
} }
if alreadyExists { if alreadyExists {
return errors.New("Target path already exists") if !overwriteTargetPath {
return fmt.Errorf("Target path already exists")
}
if err = os.RemoveAll(newPath); err != nil {
return fmt.Errorf("failed to overwrite path %s: %s", newPath, err.Error())
}
} }
return os.Rename(originalPath, newPath) return os.Rename(originalPath, newPath)
+19 -3
View File
@@ -16,7 +16,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
file1 := addFile(destinationDir, "dir", "file") file1 := addFile(destinationDir, "dir", "file")
file2 := addFile(destinationDir, "file") file2 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir) err := MoveDirectory(sourceDir, destinationDir, false)
assert.Error(t, err, "move directory should fail when source path is missing") assert.Error(t, err, "move directory should fail when source path is missing")
assert.FileExists(t, file1, "destination dir contents should remain") assert.FileExists(t, file1, "destination dir contents should remain")
assert.FileExists(t, file2, "destination dir contents should remain") assert.FileExists(t, file2, "destination dir contents should remain")
@@ -30,7 +30,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
file3 := addFile(destinationDir, "dir", "file") file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file") file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir) err := MoveDirectory(sourceDir, destinationDir, false)
assert.Error(t, err, "move directory should fail when destination directory already exists") assert.Error(t, err, "move directory should fail when destination directory already exists")
assert.FileExists(t, file1, "source dir contents should remain") assert.FileExists(t, file1, "source dir contents should remain")
assert.FileExists(t, file2, "source dir contents should remain") assert.FileExists(t, file2, "source dir contents should remain")
@@ -38,6 +38,22 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
assert.FileExists(t, file4, "destination dir contents should remain") assert.FileExists(t, file4, "destination dir contents should remain")
} }
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
sourceDir := t.TempDir()
file1 := addFile(sourceDir, "dir", "file")
file2 := addFile(sourceDir, "file")
destinationDir := t.TempDir()
file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, true)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assert.FileExists(t, file3, "destination dir contents should remain")
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) { func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
sourceDir := path.Join(tmp, "source") sourceDir := path.Join(tmp, "source")
@@ -46,7 +62,7 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
file2 := addFile(sourceDir, "file") file2 := addFile(sourceDir, "file")
destinationDir := path.Join(tmp, "destination") destinationDir := path.Join(tmp, "destination")
err := MoveDirectory(sourceDir, destinationDir) err := MoveDirectory(sourceDir, destinationDir, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved") assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved") assert.NoFileExists(t, file2, "source dir contents should be moved")
+2 -2
View File
@@ -38,7 +38,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
} }
} }
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath) err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath, true)
if err != nil { if err != nil {
return cleanFn, errors.WithMessage(err, "Unable to move git repository directory") return cleanFn, errors.WithMessage(err, "Unable to move git repository directory")
} }
@@ -48,7 +48,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify) err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify)
if err != nil { if err != nil {
cleanUp = false cleanUp = false
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath) restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false)
if restoreError != nil { if restoreError != nil {
log.Warn().Err(restoreError).Msg("failed restoring backup folder") log.Warn().Err(restoreError).Msg("failed restoring backup folder")
} }
+5 -1
View File
@@ -21,7 +21,11 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err) return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
} }
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler) handler = gorillacsrf.Protect(
[]byte(token),
gorillacsrf.Path("/"),
gorillacsrf.Secure(false),
)(handler)
return withSkipCSRF(handler), nil return withSkipCSRF(handler), nil
} }
+11 -2
View File
@@ -75,7 +75,12 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
if settings.AuthenticationMethod == portainer.AuthenticationInternal || if settings.AuthenticationMethod == portainer.AuthenticationInternal ||
settings.AuthenticationMethod == portainer.AuthenticationOAuth || settings.AuthenticationMethod == portainer.AuthenticationOAuth ||
(settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) { (settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized) // avoid username enumeration timing attack by creating a fake user
// https://en.wikipedia.org/wiki/Timing_attack
user = &portainer.User{
Username: "portainer-fake-username",
Password: "$2a$10$abcdefghijklmnopqrstuvwx..ABCDEFGHIJKLMNOPQRSTUVWXYZ12", // fake but valid format bcrypt hash
}
} }
} }
@@ -112,7 +117,11 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError { func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings) err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
if err != nil { if err != nil {
return httperror.Forbidden("Only initial admin is allowed to login without oauth", err) if errors.Is(err, httperrors.ErrUnauthorized) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
}
return httperror.InternalServerError("Unable to authenticate user against LDAP", err)
} }
if user == nil { if user == nil {
@@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
) )
type ImageResponse struct { type ImageResponse struct {
@@ -63,7 +64,9 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imageUsageSet := set.Set[string]{} imageUsageSet := set.Set[string]{}
if withUsage { if withUsage {
containers, err := cli.ContainerList(r.Context(), types.ContainerListOptions{}) containers, err := cli.ContainerList(r.Context(), container.ListOptions{
All: true,
})
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker containers", err) return httperror.InternalServerError("Unable to retrieve Docker containers", err)
} }
@@ -135,6 +135,11 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
} }
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) { func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
delete(stack.Status, environmentId)
return
}
environmentStatus, ok := stack.Status[environmentId] environmentStatus, ok := stack.Status[environmentId]
if !ok { if !ok {
environmentStatus = portainer.EdgeStackStatus{ environmentStatus = portainer.EdgeStackStatus{
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/tag" "github.com/portainer/portainer/api/internal/tag"
pendingActionActions "github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -159,7 +160,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
err := handler.PendingActionsService.Create(portainer.PendingActions{ err := handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointID, EndpointID: endpointID,
Action: "CleanNAPWithOverridePolicies", Action: "CleanNAPWithOverridePolicies",
ActionData: endpointGroupID, ActionData: &pendingActionActions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: endpointGroupID,
},
}) })
if err != nil { if err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID) log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
@@ -179,6 +179,12 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
} }
} }
// delete the pending actions
err = tx.PendingActions().DeleteByEndpointID(endpoint.ID)
if err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msgf("Unable to delete pending actions")
}
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID)) err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to delete the environment from the database", err) return httperror.InternalServerError("Unable to delete the environment from the database", err)
@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService()) handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
handler.DataStore = store handler.DataStore = store
handler.ProxyManager = proxy.NewManager(nil, nil, nil, nil, nil, nil, nil) handler.ProxyManager = proxy.NewManager(nil)
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
// Create all the environments and add them to the same edge group // Create all the environments and add them to the same edge group
@@ -12,8 +12,8 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
dockertypes "github.com/docker/docker/api/types" dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
) )
@@ -39,7 +39,7 @@ func (payload *forceUpdateServicePayload) Validate(r *http.Request) error {
// @produce json // @produce json
// @param id path int true "endpoint identifier" // @param id path int true "endpoint identifier"
// @param body body forceUpdateServicePayload true "details" // @param body body forceUpdateServicePayload true "details"
// @success 200 {object} dockertypes.ServiceUpdateResponse "Success" // @success 200 {object} swarm.ServiceUpdateResponse "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 403 "Permission denied" // @failure 403 "Permission denied"
// @failure 404 "endpoint not found" // @failure 404 "endpoint not found"
@@ -94,7 +94,7 @@ func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *htt
go func() { go func() {
images.EvictImageStatus(payload.ServiceID) images.EvictImageStatus(payload.ServiceID)
images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel]) images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel])
containers, _ := dockerClient.ContainerList(context.TODO(), types.ContainerListOptions{ containers, _ := dockerClient.ContainerList(context.TODO(), container.ListOptions{
All: true, All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)), Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)),
}) })
+1
View File
@@ -622,6 +622,7 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
portainer.EdgeStackStatusRunning, portainer.EdgeStackStatusRunning,
portainer.EdgeStackStatusDeploying, portainer.EdgeStackStatusDeploying,
portainer.EdgeStackStatusRemoving, portainer.EdgeStackStatusRemoving,
portainer.EdgeStackStatusCompleted,
}, edgeStackStatus) { }, edgeStackStatus) {
return nil, errors.New("invalid edgeStackStatus parameter") return nil, errors.New("invalid edgeStackStatus parameter")
} }
+1 -1
View File
@@ -85,7 +85,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.20.0 // @version 2.20.3
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService
+9 -6
View File
@@ -38,19 +38,20 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
kubeClusterAccessService: kubeClusterAccessService, kubeClusterAccessService: kubeClusterAccessService,
} }
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"),
bouncer.AuthenticatedAccess)
// `helm list -o json` // `helm list -o json`
h.Handle("/{id}/kubernetes/helm", h.Handle("/{id}/kubernetes/helm",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet) httperror.LoggerHandler(h.helmList)).Methods(http.MethodGet)
// `helm delete RELEASE_NAME` // `helm delete RELEASE_NAME`
h.Handle("/{id}/kubernetes/helm/{release}", h.Handle("/{id}/kubernetes/helm/{release}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete) httperror.LoggerHandler(h.helmDelete)).Methods(http.MethodDelete)
// `helm install [NAME] [CHART] flags` // `helm install [NAME] [CHART] flags`
h.Handle("/{id}/kubernetes/helm", h.Handle("/{id}/kubernetes/helm",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost) httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
// Deprecated // Deprecated
h.Handle("/{id}/kubernetes/helm/repositories", h.Handle("/{id}/kubernetes/helm/repositories",
@@ -69,12 +70,14 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
requestBouncer: bouncer, requestBouncer: bouncer,
} }
h.Use(bouncer.AuthenticatedAccess)
h.Handle("/templates/helm", h.Handle("/templates/helm",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet) httperror.LoggerHandler(h.helmRepoSearch)).Methods(http.MethodGet)
// helm show [COMMAND] [CHART] [REPO] flags // helm show [COMMAND] [CHART] [REPO] flags
h.Handle("/templates/helm/{command:chart|values|readme}", h.Handle("/templates/helm/{command:chart|values|readme}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet) httperror.LoggerHandler(h.helmShow)).Methods(http.MethodGet)
return h return h
} }
+1 -2
View File
@@ -61,8 +61,7 @@ func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *htt
return httperror.InternalServerError("Unable to install a chart", err) return httperror.InternalServerError("Unable to install a chart", err)
} }
w.WriteHeader(http.StatusCreated) return response.JSONWithStatus(w, release, http.StatusCreated)
return response.JSON(w, release)
} }
func (p *installChartPayload) Validate(_ *http.Request) error { func (p *installChartPayload) Validate(_ *http.Request) error {
@@ -155,7 +155,7 @@ func pullImage(ctx context.Context, docker *client.Client, imageName string) err
// runContainer should be used to run a short command that returns information to stdout // runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support // TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) { func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := types.ContainerListOptions{All: true} opts := container.ListOptions{All: true}
opts.Filters = filters.NewArgs() opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName) opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts) existingContainers, err := docker.ContainerList(ctx, opts)
@@ -170,7 +170,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
} }
if len(existingContainers) > 0 { if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true}) err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
if err != nil { if err != nil {
log.Error(). log.Error().
Str("image_name", imageName). Str("image_name", imageName).
@@ -211,7 +211,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
return "", err return "", err
} }
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{}) err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
if err != nil { if err != nil {
log.Error(). log.Error().
Str("image_name", imageName). Str("image_name", imageName).
@@ -243,14 +243,14 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
log.Debug().Int64("status", statusCode).Msg("container wait status") log.Debug().Int64("status", statusCode).Msg("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true}) out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
if err != nil { if err != nil {
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log") log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
return "", err return "", err
} }
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{}) err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
if err != nil { if err != nil {
log.Error(). log.Error().
Str("image_name", imageName). Str("image_name", imageName).
@@ -8,6 +8,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/pendingactions" "github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -91,7 +92,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) er
if len(failedNamespaces) > 0 { if len(failedNamespaces) > 0 {
handler.PendingActionsService.Create(portainer.PendingActions{ handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointId, EndpointID: endpointId,
Action: pendingactions.DeletePortainerK8sRegistrySecrets, Action: actions.DeletePortainerK8sRegistrySecrets,
// When extracting the data, this is the type we need to pull out // When extracting the data, this is the type we need to pull out
// i.e. pendingactions.DeletePortainerK8sRegistrySecretsData // i.e. pendingactions.DeletePortainerK8sRegistrySecretsData
@@ -13,6 +13,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"golang.org/x/oauth2"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -95,6 +96,11 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
} }
} }
if payload.OAuthSettings != nil {
if payload.OAuthSettings.AuthStyle < oauth2.AuthStyleAutoDetect || payload.OAuthSettings.AuthStyle > oauth2.AuthStyleInHeader {
return errors.New("Invalid OAuth AuthStyle")
}
}
return nil return nil
} }
@@ -225,6 +231,7 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.OAuthSettings = *payload.OAuthSettings settings.OAuthSettings = *payload.OAuthSettings
settings.OAuthSettings.ClientSecret = clientSecret settings.OAuthSettings.ClientSecret = clientSecret
settings.OAuthSettings.KubeSecretKey = kubeSecret settings.OAuthSettings.KubeSecretKey = kubeSecret
settings.OAuthSettings.AuthStyle = payload.OAuthSettings.AuthStyle
} }
if payload.EnableEdgeComputeFeatures != nil { if payload.EnableEdgeComputeFeatures != nil {
+2 -1
View File
@@ -21,6 +21,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -190,7 +191,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
} }
} }
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true}) containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil { if err != nil {
return false, err return false, err
} }
+38 -7
View File
@@ -23,6 +23,7 @@ type stackListOperationFilters struct {
// @description List all stacks based on the current user authorizations. // @description List all stacks based on the current user authorizations.
// @description Will return all stacks if using an administrator account otherwise it // @description Will return all stacks if using an administrator account otherwise it
// @description will only return the list of stacks the user have access to. // @description will only return the list of stacks the user have access to.
// @description Limited stacks will not be returned by this endpoint.
// @description **Access policy**: authenticated // @description **Access policy**: authenticated
// @tags stacks // @tags stacks
// @security ApiKeyAuth // @security ApiKeyAuth
@@ -91,25 +92,55 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return response.JSON(w, stacks) return response.JSON(w, stacks)
} }
// filterStacks refines a collection of Stack instances using specified criteria.
// This function examines the provided filters: EndpointID, SwarmID, and IncludeOrphanedStacks.
// - If both EndpointID is zero and SwarmID is an empty string, the function directly returns the original stack list without any modifications.
// - If either filter is specified, it proceeds to selectively include stacks that match the criteria.
// Key Points on Business Logic:
// 1. Determining Inclusion of Orphaned Stacks:
// - The decision to include orphaned stacks is influenced by the user's role and usually set by the client (UI).
// - Administrators or environment administrators can include orphaned stacks by setting IncludeOrphanedStacks to true, reflecting their broader access rights.
// - For non-administrative users, this is typically set to false, limiting their visibility to only stacks within their purview.
// 2. Inclusion Criteria for Orphaned Stacks:
// - When IncludeOrphanedStacks is true and an EndpointID is specified (not zero), the function selects:
// a) Stacks linked to the specified EndpointID.
// b) Orphaned stacks that don't have a naming conflict with any stack associated with the EndpointID.
// - This approach is designed to avoid name conflicts within Docker Compose, which restricts the creation of multiple stacks with the same name.
// 3. Type Matching for Orphaned Stacks:
// - The function ensures that orphaned stacks are compatible with the environment's stack type (compose or swarm).
// - It filters out orphaned swarm stacks in Docker standalone environments
// - It filters out orphaned standalone stack in Docker swarm environments
// - This ensures that re-association respects the constraints of the environment and stack type.
// The outcome is a new list of stacks that align with these filtering and business logic criteria.
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack { func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
if filters.EndpointID == 0 && filters.SwarmID == "" { if filters.EndpointID == 0 && filters.SwarmID == "" {
return stacks return stacks
} }
filteredStacks := make([]portainer.Stack, 0, len(stacks)) filteredStacks := make([]portainer.Stack, 0, len(stacks))
uniqueStackNames := make(map[string]struct{})
for _, stack := range stacks { for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
filteredStacks = append(filteredStacks, stack)
}
continue
}
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
filteredStacks = append(filteredStacks, stack) filteredStacks = append(filteredStacks, stack)
uniqueStackNames[stack.Name] = struct{}{}
} }
if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID { if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID {
filteredStacks = append(filteredStacks, stack) filteredStacks = append(filteredStacks, stack)
uniqueStackNames[stack.Name] = struct{}{}
}
}
for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
if _, exists := uniqueStackNames[stack.Name]; !exists {
filteredStacks = append(filteredStacks, stack)
}
}
} }
} }
@@ -0,0 +1,74 @@
package stacks
import (
"sort"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestFilterStacks(t *testing.T) {
t.Run("filter stacks against particular endpoint and all orphaned stacks", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
t.Run("filter unique stacks against particular endpoint and all orphaned stacks and an orphaned stack has the same name with normal stack", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
t.Run("only filter stacks against particular endpoint and no orphaned stacks", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: false}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
}
func isEqualStacks(t *testing.T, expectStacks, actualStacks []portainer.Stack) {
expectStackIDs := make([]int, len(expectStacks))
for i, stack := range expectStacks {
expectStackIDs[i] = int(stack.ID)
}
sort.Ints(expectStackIDs)
actualStackIDs := make([]int, len(actualStacks))
for i, stack := range actualStacks {
actualStackIDs[i] = int(stack.ID)
}
sort.Ints(actualStackIDs)
assert.Equal(t, expectStackIDs, actualStackIDs)
}
@@ -27,6 +27,8 @@ type stackGitRedployPayload struct {
Prune bool Prune bool
// Force a pulling to current image with the original tag though the image is already the latest // Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"` PullImage bool `example:"false"`
StackName string
} }
func (payload *stackGitRedployPayload) Validate(r *http.Request) error { func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
@@ -44,7 +46,7 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
// @produce json // @produce json
// @param id path int true "Stack identifier" // @param id path int true "Stack identifier"
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." // @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack."
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy a stack" // @param body body stackGitRedployPayload true "Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank"
// @success 200 {object} portainer.Stack "Success" // @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 403 "Permission denied" // @failure 403 "Permission denied"
@@ -136,6 +138,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
} }
} }
if stack.Type == portainer.KubernetesStack {
stack.Name = payload.StackName
}
repositoryUsername := "" repositoryUsername := ""
repositoryPassword := "" repositoryPassword := ""
if payload.RepositoryAuthentication { if payload.RepositoryAuthentication {
@@ -2,6 +2,7 @@ package users
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@@ -20,9 +21,6 @@ type userAccessTokenCreatePayload struct {
} }
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error { func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Password) {
return errors.New("invalid password: cannot be empty")
}
if govalidator.IsNull(payload.Description) { if govalidator.IsNull(payload.Description) {
return errors.New("invalid description: cannot be empty") return errors.New("invalid description: cannot be empty")
} }
@@ -44,6 +42,7 @@ type accessTokenResponse struct {
// @summary Generate an API key for a user // @summary Generate an API key for a user
// @description Generates an API key for a user. // @description Generates an API key for a user.
// @description Only the calling user can generate a token for themselves. // @description Only the calling user can generate a token for themselves.
// @description Password is required only for internal authentication.
// @description **Access policy**: restricted // @description **Access policy**: restricted
// @tags users // @tags users
// @security jwt // @security jwt
@@ -51,7 +50,7 @@ type accessTokenResponse struct {
// @produce json // @produce json
// @param id path int true "User identifier" // @param id path int true "User identifier"
// @param body body userAccessTokenCreatePayload true "details" // @param body body userAccessTokenCreatePayload true "details"
// @success 201 {object} accessTokenResponse "Created" // @success 200 {object} accessTokenResponse "Created"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 401 "Unauthorized" // @failure 401 "Unauthorized"
// @failure 403 "Permission denied" // @failure 403 "Permission denied"
@@ -60,8 +59,13 @@ type accessTokenResponse struct {
// @router /users/{id}/tokens [post] // @router /users/{id}/tokens [post]
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported // specifically require Cookie auth for this endpoint since API-Key based auth is not supported
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil { jwt, _ := handler.bouncer.CookieAuthLookup(r)
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required")) if jwt == nil {
jwt, _ = handler.bouncer.JWTAuthLookup(r)
}
if jwt == nil {
return httperror.Unauthorized("Auth not supported", errors.New("Authentication required"))
} }
var payload userAccessTokenCreatePayload var payload userAccessTokenCreatePayload
@@ -89,9 +93,21 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err) return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
} }
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password) internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
if err != nil { if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")) return httperror.InternalServerError("Unable to determine the authentication method", err)
}
if internalAuth {
// Internal auth requires the password field and must not be empty
if govalidator.IsNull(payload.Password) {
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
} }
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description) rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
@@ -99,6 +115,20 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Internal Server Error", err) return httperror.InternalServerError("Internal Server Error", err)
} }
w.WriteHeader(http.StatusCreated) return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey}) }
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
return true, nil
}
// otherwise determine the auth method from the settings
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
}
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
} }
@@ -107,7 +107,7 @@ func Test_userCreateAccessToken(t *testing.T) {
body, err := io.ReadAll(rr.Body) body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error") is.NoError(err, "ReadAll should not return error")
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body)) is.Equal(`{"message":"Auth not supported","details":"Authentication required"}`, string(body))
}) })
} }
+1 -1
View File
@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
// @tags webhooks // @tags webhooks
// @accept json // @accept json
// @produce json // @produce json
// @param filters query webhookListOperationFilters false "Filters" // @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
// @success 200 {array} portainer.Webhook // @success 200 {array} portainer.Webhook
// @failure 400 // @failure 400
// @failure 500 // @failure 500
+1 -1
View File
@@ -65,7 +65,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
DockerClientFactory: factory.dockerClientFactory, DockerClientFactory: factory.dockerClientFactory,
} }
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService) dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService, factory.snapshotService)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -1
View File
@@ -36,6 +36,7 @@ type (
reverseTunnelService portainer.ReverseTunnelService reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *dockerclient.ClientFactory dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService gitService portainer.GitService
snapshotService portainer.SnapshotService
} }
// TransportParameters is used to create a new Transport // TransportParameters is used to create a new Transport
@@ -63,7 +64,7 @@ type (
) )
// NewTransport returns a pointer to a new Transport instance. // NewTransport returns a pointer to a new Transport instance.
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService) (*Transport, error) { func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) {
transport := &Transport{ transport := &Transport{
endpoint: parameters.Endpoint, endpoint: parameters.Endpoint,
dataStore: parameters.DataStore, dataStore: parameters.DataStore,
@@ -72,6 +73,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
dockerClientFactory: parameters.DockerClientFactory, dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport, HTTPTransport: httpTransport,
gitService: gitService, gitService: gitService,
snapshotService: snapshotService,
} }
return transport, nil return transport, nil
+9
View File
@@ -8,6 +8,7 @@ import (
"path" "path"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/proxy/factory/utils"
@@ -48,6 +49,14 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
if responseObject["Volumes"] != nil { if responseObject["Volumes"] != nil {
volumeData := responseObject["Volumes"].([]interface{}) volumeData := responseObject["Volumes"].([]interface{})
if transport.snapshotService != nil {
// Filling snapshot data can improve the performance of getVolumeResourceID
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
log.Info().Err(err).
Int("endpoint id", int(transport.endpoint.ID)).
Msg("snapshot is not filled into the endpoint.")
}
}
for _, volumeObject := range volumeData { for _, volumeObject := range volumeData {
volume := volumeObject.(map[string]interface{}) volume := volumeObject.(map[string]interface{})
+1 -1
View File
@@ -22,7 +22,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
proxy := &dockerLocalProxy{} proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService) dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+1 -1
View File
@@ -23,7 +23,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
proxy := &dockerLocalProxy{} proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService) dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -1
View File
@@ -23,11 +23,12 @@ type (
kubernetesClientFactory *cli.ClientFactory kubernetesClientFactory *cli.ClientFactory
kubernetesTokenCacheManager *kubernetes.TokenCacheManager kubernetesTokenCacheManager *kubernetes.TokenCacheManager
gitService portainer.GitService gitService portainer.GitService
snapshotService portainer.SnapshotService
} }
) )
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory // NewProxyFactory returns a pointer to a new instance of a ProxyFactory
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *ProxyFactory { func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
return &ProxyFactory{ return &ProxyFactory{
dataStore: dataStore, dataStore: dataStore,
signatureService: signatureService, signatureService: signatureService,
@@ -36,6 +37,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
kubernetesClientFactory: kubernetesClientFactory, kubernetesClientFactory: kubernetesClientFactory,
kubernetesTokenCacheManager: kubernetesTokenCacheManager, kubernetesTokenCacheManager: kubernetesTokenCacheManager,
gitService: gitService, gitService: gitService,
snapshotService: snapshotService,
} }
} }
+15 -2
View File
@@ -25,17 +25,24 @@ type (
) )
// NewManager initializes a new proxy Service // NewManager initializes a new proxy Service
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager { func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
return &Manager{ return &Manager{
endpointProxies: cmap.New(), endpointProxies: cmap.New(),
k8sClientFactory: kubernetesClientFactory, k8sClientFactory: kubernetesClientFactory,
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
} }
} }
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
}
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies. // CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. // It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint) proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -48,6 +55,9 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies. // CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. // It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) { func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
return manager.proxyFactory.NewAgentProxy(endpoint) return manager.proxyFactory.NewAgentProxy(endpoint)
} }
@@ -74,5 +84,8 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API // CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) { func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
return manager.proxyFactory.NewGitlabProxy(url) return manager.proxyFactory.NewGitlabProxy(url)
} }
+8 -4
View File
@@ -1,6 +1,7 @@
package security package security
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -10,6 +11,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -27,6 +29,7 @@ type (
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
CookieAuthLookup(*http.Request) (*portainer.TokenData, error) CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
} }
// RequestBouncer represents an entity that manages API request accesses // RequestBouncer represents an entity that manages API request accesses
@@ -280,7 +283,7 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
for _, lookup := range tokenLookups { for _, lookup := range tokenLookups {
resultToken, err := lookup(r) resultToken, err := lookup(r)
if err != nil { if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized) httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized)
return return
} }
@@ -316,7 +319,7 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token) tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil { if err != nil {
return nil, ErrInvalidKey return nil, err
} }
return tokenData, nil return tokenData, nil
@@ -332,7 +335,7 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token) tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil { if err != nil {
return nil, ErrInvalidKey return nil, err
} }
return tokenData, nil return tokenData, nil
@@ -366,7 +369,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
Role: user.Role, Role: user.Role,
} }
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil { if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
return nil, ErrInvalidKey log.Debug().Err(err).Msg("Failed to generate token")
return nil, fmt.Errorf("failed to generate token")
} }
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds] if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
+2 -2
View File
@@ -61,7 +61,6 @@ import (
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks" edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/ssl" "github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/internal/upgrade" "github.com/portainer/portainer/api/internal/upgrade"
k8s "github.com/portainer/portainer/api/kubernetes" k8s "github.com/portainer/portainer/api/kubernetes"
@@ -382,7 +381,8 @@ func (server *Server) Start() error {
go shutdown(server.ShutdownCtx, httpsServer) go shutdown(server.ShutdownCtx, httpsServer)
go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService) // Temporarily disable for EE-6905 until we have a solution for the snapshotter
// go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
return httpsServer.ListenAndServeTLS("", "") return httpsServer.ListenAndServeTLS("", "")
} }
@@ -54,6 +54,10 @@ func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenDat
return nil, nil return nil, nil
} }
func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
return nil, nil
}
// AddTestSecurityCookie adds a security cookie to the request // AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) { func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{ r.AddCookie(&http.Cookie{
+5 -4
View File
@@ -3,10 +3,11 @@ package portainer
func KubernetesDefault() KubernetesData { func KubernetesDefault() KubernetesData {
return KubernetesData{ return KubernetesData{
Configuration: KubernetesConfiguration{ Configuration: KubernetesConfiguration{
UseLoadBalancer: false, UseLoadBalancer: false,
UseServerMetrics: false, UseServerMetrics: false,
StorageClasses: []KubernetesStorageClassConfig{}, EnableResourceOverCommit: true,
IngressClasses: []KubernetesIngressClassConfig{}, StorageClasses: []KubernetesStorageClassConfig{},
IngressClasses: []KubernetesIngressClassConfig{},
}, },
Snapshots: []KubernetesSnapshot{}, Snapshots: []KubernetesSnapshot{},
} }
+116 -101
View File
@@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -80,22 +81,31 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it. // If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
factory.mu.Lock()
key := strconv.Itoa(int(endpoint.ID))
if client, ok := factory.endpointClients[key]; ok {
factory.mu.Unlock()
return client, nil
}
factory.mu.Unlock()
// EE-6901: Do not lock
client, err := factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.mu.Lock() factory.mu.Lock()
defer factory.mu.Unlock() defer factory.mu.Unlock()
key := strconv.Itoa(int(endpoint.ID)) // The lock was released before the client was created,
client, ok := factory.endpointClients[key] // so we need to check again
if !ok { if c, ok := factory.endpointClients[key]; ok {
var err error return c, nil
client, err = factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.endpointClients[key] = client
} }
factory.endpointClients[key] = client
return client, nil return client, nil
} }
@@ -277,106 +287,111 @@ func buildLocalConfig() (*rest.Config, error) {
return config, nil return config, nil
} }
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error { func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, datastore dataservices.DataStore, cli *KubeClient) error {
// classes is a list of controllers which have been manually added to the return datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
// cluster setup view. These need to all be allowed globally, but then environment, err := tx.Endpoint().Endpoint(e.ID)
// blocked in specific namespaces which they were not previously allowed in.
classes := e.Kubernetes.Configuration.IngressClasses
// We need a kube client to gather namespace level permissions. In pre-2.16
// versions of portainer, the namespace level permissions were stored by
// creating an actual ingress rule in the cluster with a particular
// annotation indicating that it's name (the class name) should be allowed.
cli, err := factory.GetKubeClient(e)
if err != nil {
return err
}
detected, err := cli.GetIngressControllers()
if err != nil {
return err
}
// newControllers is a set of all currently detected controllers.
newControllers := make(map[string]struct{})
for _, controller := range detected {
newControllers[controller.ClassName] = struct{}{}
}
namespaces, err := cli.GetNamespaces()
if err != nil {
return err
}
// Set of namespaces, if any, in which "allow none" should be true.
allow := make(map[string]map[string]struct{})
for _, c := range classes {
allow[c.Name] = make(map[string]struct{})
}
allow["none"] = make(map[string]struct{})
for namespace := range namespaces {
// Compare old annotations with currently detected controllers.
ingresses, err := cli.GetIngresses(namespace)
if err != nil { if err != nil {
return fmt.Errorf("failure getting ingresses during migration") log.Error().Err(err).Msgf("Error retrieving environment %d", e.ID)
return err
} }
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
// Skip rules without our old annotation.
continue
}
if _, ok := newControllers[oldController]; ok { // classes is a list of controllers which have been manually added to the
// Skip rules which match a detected controller. // cluster setup view. These need to all be allowed globally, but then
// TODO: Allow this particular controller. // blocked in specific namespaces which they were not previously allowed in.
allow[oldController][ingress.Namespace] = struct{}{} classes := environment.Kubernetes.Configuration.IngressClasses
continue
}
allow["none"][ingress.Namespace] = struct{}{} // In pre-2.16 versions of portainer, the namespace level permissions were stored by
// creating an actual ingress rule in the cluster with a particular
// annotation indicating that it's name (the class name) should be allowed.
detected, err := cli.GetIngressControllers()
if err != nil {
log.Error().Err(err).Msgf("Error getting ingress controllers in environment %d", environment.ID)
return err
} }
}
// Locally, disable "allow none" for namespaces not inside shouldAllowNone. // newControllers is a set of all currently detected controllers.
var newClasses []portainer.KubernetesIngressClassConfig newControllers := make(map[string]struct{})
for _, c := range classes { for _, controller := range detected {
var blocked []string newControllers[controller.ClassName] = struct{}{}
}
namespaces, err := cli.GetNamespaces()
if err != nil {
log.Error().Err(err).Msgf("Error getting namespaces in environment %d", environment.ID)
return err
}
// Set of namespaces, if any, in which "allow none" should be true.
allow := make(map[string]map[string]struct{})
for _, c := range classes {
allow[c.Name] = make(map[string]struct{})
}
allow["none"] = make(map[string]struct{})
for namespace := range namespaces { for namespace := range namespaces {
if _, ok := allow[c.Name][namespace]; ok { // Compare old annotations with currently detected controllers.
continue ingresses, err := cli.GetIngresses(namespace)
if err != nil {
log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID)
return err
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
// Skip rules without our old annotation.
continue
}
if _, ok := newControllers[oldController]; ok {
// Skip rules which match a detected controller.
// TODO: Allow this particular controller.
allow[oldController][ingress.Namespace] = struct{}{}
continue
}
allow["none"][ingress.Namespace] = struct{}{}
} }
blocked = append(blocked, namespace)
} }
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{ // Locally, disable "allow none" for namespaces not inside shouldAllowNone.
Name: c.Name, var newClasses []portainer.KubernetesIngressClassConfig
Type: c.Type, for _, c := range classes {
GloballyBlocked: false, var blocked []string
BlockedNamespaces: blocked, for namespace := range namespaces {
}) if _, ok := allow[c.Name][namespace]; ok {
} continue
}
// Handle "none". blocked = append(blocked, namespace)
if len(allow["none"]) != 0 {
e.Kubernetes.Configuration.AllowNoneIngressClass = true
var disallowNone []string
for namespace := range namespaces {
if _, ok := allow["none"][namespace]; ok {
continue
} }
disallowNone = append(disallowNone, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: "none",
Type: "custom",
GloballyBlocked: false,
BlockedNamespaces: disallowNone,
})
}
e.Kubernetes.Configuration.IngressClasses = newClasses newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
e.PostInitMigrations.MigrateIngresses = false Name: c.Name,
return factory.dataStore.Endpoint().UpdateEndpoint(e.ID, e) Type: c.Type,
GloballyBlocked: false,
BlockedNamespaces: blocked,
})
}
// Handle "none".
if len(allow["none"]) != 0 {
environment.Kubernetes.Configuration.AllowNoneIngressClass = true
var disallowNone []string
for namespace := range namespaces {
if _, ok := allow["none"][namespace]; ok {
continue
}
disallowNone = append(disallowNone, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: "none",
Type: "custom",
GloballyBlocked: false,
BlockedNamespaces: disallowNone,
})
}
environment.Kubernetes.Configuration.IngressClasses = newClasses
environment.PostInitMigrations.MigrateIngresses = false
return tx.Endpoint().UpdateEndpoint(environment.ID, environment)
})
} }
+6 -2
View File
@@ -241,7 +241,10 @@ func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) err
// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint. // UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error { func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error {
ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace) ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace)
var ingress netv1.Ingress ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{})
if err != nil {
return err
}
ingress.Name = info.Name ingress.Name = info.Name
ingress.Namespace = info.Namespace ingress.Namespace = info.Namespace
@@ -278,6 +281,7 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
}) })
} }
ingress.Spec.Rules = make([]netv1.IngressRule, 0)
for rule, paths := range rules { for rule, paths := range rules {
ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{ ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{
Host: rule, Host: rule,
@@ -299,6 +303,6 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
} }
} }
_, err := ingressClient.Update(context.Background(), &ingress, metav1.UpdateOptions{}) _, err = ingressClient.Update(context.Background(), ingress, metav1.UpdateOptions{})
return err return err
} }
+13 -14
View File
@@ -73,31 +73,30 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
ns.Annotations = info.Annotations ns.Annotations = info.Annotations
ns.Labels = portainerLabels ns.Labels = portainerLabels
resourceQuota := &v1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "portainer-rq-" + info.Name,
Namespace: info.Name,
Labels: portainerLabels,
},
Spec: v1.ResourceQuotaSpec{
Hard: v1.ResourceList{},
},
}
_, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{}) _, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
if err != nil { if err != nil {
log.Error(). log.Error().
Err(err). Err(err).
Str("Namespace", info.Name). Str("Namespace", info.Name).
Interface("ResourceQuota", resourceQuota). Msg("Failed to create the namespace")
Msg("Failed to create the namespace due to a resource quota issue.")
return err return err
} }
if info.ResourceQuota != nil { if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
log.Info().Msgf("Creating resource quota for namespace %s", info.Name) log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota) log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
resourceQuota := &v1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "portainer-rq-" + info.Name,
Namespace: info.Name,
Labels: portainerLabels,
},
Spec: v1.ResourceQuotaSpec{
Hard: v1.ResourceList{},
},
}
if info.ResourceQuota.Enabled { if info.ResourceQuota.Enabled {
memory := resource.MustParse(info.ResourceQuota.Memory) memory := resource.MustParse(info.ResourceQuota.Memory)
cpu := resource.MustParse(info.ResourceQuota.CPU) cpu := resource.MustParse(info.ResourceQuota.CPU)
+19 -4
View File
@@ -125,12 +125,27 @@ func GetNamespace(manifestYaml []byte) (string, error) {
return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace") return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace")
} }
if _, ok := m["metadata"]; ok { kind, ok := m["kind"].(string)
if namespace, ok := m["metadata"].(map[string]interface{})["namespace"]; ok { if !ok {
return namespace.(string), nil return "", errors.New("invalid kubernetes manifest, missing 'kind' field")
}
} }
if _, ok := m["metadata"]; ok {
var namespace interface{}
var ok bool
if strings.EqualFold(kind, "namespace") {
namespace, ok = m["metadata"].(map[string]interface{})["name"]
} else {
namespace, ok = m["metadata"].(map[string]interface{})["namespace"]
}
if ok {
if v, ok := namespace.(string); ok {
return v, nil
}
return "", errors.New("invalid kubernetes manifest, 'namespace' field is not a string")
}
}
return "", nil return "", nil
} }
+1 -1
View File
@@ -648,7 +648,7 @@ func Test_GetNamespace(t *testing.T) {
input: `apiVersion: v1 input: `apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
namespace: test-namespace name: test-namespace
`, `,
want: "test-namespace", want: "test-namespace",
}, },
+8 -1
View File
@@ -75,7 +75,14 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
userDN, err := searchUser(username, connection, settings.SearchSettings) userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil { if err != nil {
return err if errors.Is(err, errUserNotFound) {
// prevent user enumeration timing attack by attempting the bind with a fake user
// and whatever password was provided should definately fail
// https://en.wikipedia.org/wiki/Timing_attack
userDN = "portainer-fake-ldap-username"
} else {
return err
}
} }
err = connection.Bind(userDN, password) err = connection.Bind(userDN, password)
+3 -2
View File
@@ -172,8 +172,9 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config { func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
endpoint := oauth2.Endpoint{ endpoint := oauth2.Endpoint{
AuthURL: configuration.AuthorizationURI, AuthURL: configuration.AuthorizationURI,
TokenURL: configuration.AccessTokenURI, TokenURL: configuration.AccessTokenURI,
AuthStyle: configuration.AuthStyle,
} }
return &oauth2.Config{ return &oauth2.Config{
+7
View File
@@ -0,0 +1,7 @@
package actions
const (
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
PostInitMigrateEnvironment = "PostInitMigrateEnvironment"
)
+44
View File
@@ -0,0 +1,44 @@
package actions
import (
"fmt"
portainer "github.com/portainer/portainer/api"
)
type (
CleanNAPWithOverridePoliciesPayload struct {
EndpointGroupID portainer.EndpointGroupID
}
)
func ConvertCleanNAPWithOverridePoliciesPayload(actionData interface{}) (*CleanNAPWithOverridePoliciesPayload, error) {
var payload CleanNAPWithOverridePoliciesPayload
if actionData == nil {
return nil, nil
}
// backward compatible with old data format
if endpointGroupId, ok := actionData.(float64); ok {
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupId)
return &payload, nil
}
data, ok := actionData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to convert actionData to map[string]interface{}")
}
for key, value := range data {
switch key {
case "EndpointGroupID":
if endpointGroupID, ok := value.(float64); ok {
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupID)
}
}
}
return &payload, nil
}
@@ -17,7 +17,7 @@ func (service *PendingActionsService) DeleteKubernetesRegistrySecrets(endpoint *
return nil return nil
} }
kubeClient, err := service.clientFactory.GetKubeClient(endpoint) kubeClient, err := service.kubeFactory.GetKubeClient(endpoint)
if err != nil { if err != nil {
return err return err
} }
+57 -14
View File
@@ -7,22 +7,24 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore/postinit"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
kubecli "github.com/portainer/portainer/api/kubernetes/cli" kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const (
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
)
type ( type (
PendingActionsService struct { PendingActionsService struct {
authorizationService *authorization.Service authorizationService *authorization.Service
clientFactory *kubecli.ClientFactory kubeFactory *kubecli.ClientFactory
dockerFactory *dockerClient.ClientFactory
dataStore dataservices.DataStore dataStore dataservices.DataStore
shutdownCtx context.Context shutdownCtx context.Context
assetsPath string
kubernetesDeployer portainer.KubernetesDeployer
mu sync.Mutex mu sync.Mutex
} }
@@ -30,15 +32,21 @@ type (
func NewService( func NewService(
dataStore dataservices.DataStore, dataStore dataservices.DataStore,
clientFactory *kubecli.ClientFactory, kubeFactory *kubecli.ClientFactory,
dockerFactory *dockerClient.ClientFactory,
authorizationService *authorization.Service, authorizationService *authorization.Service,
shutdownCtx context.Context, shutdownCtx context.Context,
assetsPath string,
kubernetesDeployer portainer.KubernetesDeployer,
) *PendingActionsService { ) *PendingActionsService {
return &PendingActionsService{ return &PendingActionsService{
dataStore: dataStore, dataStore: dataStore,
shutdownCtx: shutdownCtx, shutdownCtx: shutdownCtx,
authorizationService: authorizationService, authorizationService: authorizationService,
clientFactory: clientFactory, kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
assetsPath: assetsPath,
kubernetesDeployer: kubernetesDeployer,
mu: sync.Mutex{}, mu: sync.Mutex{},
} }
} }
@@ -57,9 +65,22 @@ func (service *PendingActionsService) Execute(id portainer.EndpointID) error {
return fmt.Errorf("failed to retrieve environment %d: %w", id, err) return fmt.Errorf("failed to retrieve environment %d: %w", id, err)
} }
if endpoint.Status != portainer.EndpointStatusUp { isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
// EndpointStatusUp is only relevant for non-Kubernetes endpoints
// Sometimes the endpoint is UP but the status is not updated in the database
if !isKubernetesEndpoint && endpoint.Status != portainer.EndpointStatusUp {
log.Debug().Msgf("Environment %q (id: %d) is not up", endpoint.Name, id) log.Debug().Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up: %w", endpoint.Name, id, err) return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
}
// For Kubernetes endpoints, we need to check if the endpoint is up by creating a kube client
if isKubernetesEndpoint {
_, err := service.kubeFactory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
}
} }
pendingActions, err := service.dataStore.PendingActions().ReadAll() pendingActions, err := service.dataStore.PendingActions().ReadAll()
@@ -95,13 +116,19 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
}() }()
switch pendingAction.Action { switch pendingAction.Action {
case CleanNAPWithOverridePolicies: case actions.CleanNAPWithOverridePolicies:
if (pendingAction.ActionData == nil) || (pendingAction.ActionData.(portainer.EndpointGroupID) == 0) { pendingActionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(pendingAction.ActionData)
if err != nil {
return fmt.Errorf("failed to parse pendingActionData for CleanNAPWithOverridePoliciesPayload")
}
if pendingActionData == nil || pendingActionData.EndpointGroupID == 0 {
service.authorizationService.CleanNAPWithOverridePolicies(service.dataStore, endpoint, nil) service.authorizationService.CleanNAPWithOverridePolicies(service.dataStore, endpoint, nil)
return nil return nil
} }
endpointGroupID := pendingAction.ActionData.(portainer.EndpointGroupID) endpointGroupID := pendingActionData.EndpointGroupID
endpointGroup, err := service.dataStore.EndpointGroup().Read(portainer.EndpointGroupID(endpointGroupID)) endpointGroup, err := service.dataStore.EndpointGroup().Read(portainer.EndpointGroupID(endpointGroupID))
if err != nil { if err != nil {
log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID) log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID)
@@ -114,7 +141,7 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
} }
return nil return nil
case DeletePortainerK8sRegistrySecrets: case actions.DeletePortainerK8sRegistrySecrets:
if pendingAction.ActionData == nil { if pendingAction.ActionData == nil {
return nil return nil
} }
@@ -130,6 +157,22 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
return fmt.Errorf("failed to delete kubernetes registry secrets for environment %d: %w", endpoint.ID, err) return fmt.Errorf("failed to delete kubernetes registry secrets for environment %d: %w", endpoint.ID, err)
} }
return nil
case actions.PostInitMigrateEnvironment:
postInitMigrator := postinit.NewPostInitMigrator(
service.kubeFactory,
service.dockerFactory,
service.dataStore,
service.assetsPath,
service.kubernetesDeployer,
)
err := postInitMigrator.MigrateEnvironment(endpoint)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for edge environment %d", endpoint.ID)
return fmt.Errorf("failed running post-init migrations for edge environment %d: %w", endpoint.ID, err)
}
return nil return nil
} }
+22 -16
View File
@@ -6,10 +6,13 @@ import (
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
models "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/pkg/featureflags" "github.com/portainer/portainer/pkg/featureflags"
"golang.org/x/oauth2"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
) )
@@ -242,8 +245,8 @@ type (
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"` Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"` Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"` Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
Images []types.ImageSummary `json:"Images" swaggerignore:"true"` Images []image.Summary `json:"Images" swaggerignore:"true"`
Info types.Info `json:"Info" swaggerignore:"true"` Info system.Info `json:"Info" swaggerignore:"true"`
Version types.Version `json:"Version" swaggerignore:"true"` Version types.Version `json:"Version" swaggerignore:"true"`
} }
@@ -756,19 +759,20 @@ type (
// OAuthSettings represents the settings used to authorize with an authorization server // OAuthSettings represents the settings used to authorize with an authorization server
OAuthSettings struct { OAuthSettings struct {
ClientID string `json:"ClientID"` ClientID string `json:"ClientID"`
ClientSecret string `json:"ClientSecret,omitempty"` ClientSecret string `json:"ClientSecret,omitempty"`
AccessTokenURI string `json:"AccessTokenURI"` AccessTokenURI string `json:"AccessTokenURI"`
AuthorizationURI string `json:"AuthorizationURI"` AuthorizationURI string `json:"AuthorizationURI"`
ResourceURI string `json:"ResourceURI"` ResourceURI string `json:"ResourceURI"`
RedirectURI string `json:"RedirectURI"` RedirectURI string `json:"RedirectURI"`
UserIdentifier string `json:"UserIdentifier"` UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"` Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"` DefaultTeamID TeamID `json:"DefaultTeamID"`
SSO bool `json:"SSO"` SSO bool `json:"SSO"`
LogoutURI string `json:"LogoutURI"` LogoutURI string `json:"LogoutURI"`
KubeSecretKey []byte `json:"KubeSecretKey"` KubeSecretKey []byte `json:"KubeSecretKey"`
AuthStyle oauth2.AuthStyle `json:"AuthStyle"`
} }
// Pair defines a key/value string pair // Pair defines a key/value string pair
@@ -1595,7 +1599,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.20.0" APIVersion = "2.20.3"
// Edition is what this edition of Portainer is called // Edition is what this edition of Portainer is called
Edition = PortainerCE Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1724,6 +1728,8 @@ const (
EdgeStackStatusRollingBack EdgeStackStatusRollingBack
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back // EdgeStackStatusRolledBack represents an Edge stack which has rolled back
EdgeStackStatusRolledBack EdgeStackStatusRolledBack
// EdgeStackStatusCompleted represents a completed Edge stack
EdgeStackStatusCompleted
) )
const ( const (
+6 -5
View File
@@ -16,6 +16,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/system"
dockerclient "github.com/docker/docker/client" dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -24,7 +25,7 @@ import (
) )
const ( const (
defaultUnpackerImage = "portainer/compose-unpacker:latest" defaultUnpackerImage = "portainer/compose-unpacker:" + portainer.APIVersion
composeUnpackerImageEnvVar = "COMPOSE_UNPACKER_IMAGE" composeUnpackerImageEnvVar = "COMPOSE_UNPACKER_IMAGE"
composePathPrefix = "portainer-compose-unpacker" composePathPrefix = "portainer-compose-unpacker"
) )
@@ -211,9 +212,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
if err != nil { if err != nil {
return errors.Wrap(err, "unable to create unpacker container") return errors.Wrap(err, "unable to create unpacker container")
} }
defer cli.ContainerRemove(ctx, unpackerContainer.ID, types.ContainerRemoveOptions{}) defer cli.ContainerRemove(ctx, unpackerContainer.ID, container.RemoveOptions{})
if err := cli.ContainerStart(ctx, unpackerContainer.ID, types.ContainerStartOptions{}); err != nil { if err := cli.ContainerStart(ctx, unpackerContainer.ID, container.StartOptions{}); err != nil {
return errors.Wrap(err, "start unpacker container error") return errors.Wrap(err, "start unpacker container error")
} }
@@ -228,7 +229,7 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
stdErr := &bytes.Buffer{} stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil { if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container") log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else { } else {
@@ -335,6 +336,6 @@ func getTargetSocketBind(osType string) string {
// Per https://stackoverflow.com/a/50590287 and Docker's LocalNodeState possible values // Per https://stackoverflow.com/a/50590287 and Docker's LocalNodeState possible values
// `LocalNodeStateInactive` means the node is not in a swarm cluster // `LocalNodeStateInactive` means the node is not in a swarm cluster
func isNotInASwarm(info *types.Info) bool { func isNotInASwarm(info *system.Info) bool {
return info.Swarm.LocalNodeState == swarm.LocalNodeStateInactive return info.Swarm.LocalNodeState == swarm.LocalNodeStateInactive
} }
+1
View File
@@ -82,6 +82,7 @@ function config($analyticsProvider, $windowProvider) {
push('setReferrerUrl', ''); push('setReferrerUrl', '');
push('setCustomUrl', basePath + path); push('setCustomUrl', basePath + path);
push('trackPageView'); push('trackPageView');
push('enableLinkTracking');
}); });
/** /**
+10
View File
@@ -17,6 +17,16 @@ export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions
HttpRequestHelper.resetAgentHeaders(); HttpRequestHelper.resetAgentHeaders();
}); });
// EE-6751: screens not loading when switching quickly between side menu options
// Known bug of @uirouter/angularjs
// Fix found at https://github.com/angular-ui/ui-router/issues/3652#issuecomment-574499009
// This hook is cleaning the internal viewConfigs list, removing leftover data unrelated to the current transition
$transitions.onStart({}, (transition) => {
const toList = transition.treeChanges().to.map((t) => t.state.name);
const toConfigs = transition.router.viewService._viewConfigs.filter((vc) => toList.includes(vc.viewDecl.$context.name));
transition.router.viewService._viewConfigs = toConfigs;
});
$(document).ajaxSend((event, jqXhr, jqOpts) => { $(document).ajaxSend((event, jqXhr, jqOpts) => {
const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH';
const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type'];
+1
View File
@@ -17,6 +17,7 @@
html { html {
font-size: 16px; font-size: 16px;
overflow-y: scroll; overflow-y: scroll;
scroll-behavior: smooth;
} }
html[theme='dark'], html[theme='dark'],
+26 -5
View File
@@ -104,6 +104,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controllerAs: 'ctrl', controllerAs: 'ctrl',
}, },
}, },
data: {
docs: '/user/docker/configs/add',
},
}; };
const customTemplates = { const customTemplates = {
@@ -122,7 +125,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
const customTemplatesNew = { const customTemplatesNew = {
name: 'docker.templates.custom.new', name: 'docker.templates.custom.new',
url: '/new?appTemplateId&type', url: '/new?fileContent&appTemplateId&type',
views: { views: {
'content@': { 'content@': {
@@ -165,7 +168,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
}, },
}, },
data: { data: {
docs: '/user/docker/host', docs: '/user/docker/host/details',
}, },
}; };
@@ -227,6 +230,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'BuildImageController', controller: 'BuildImageController',
}, },
}, },
data: {
docs: '/user/docker/images/build',
},
}; };
var imageImport = { var imageImport = {
@@ -238,6 +244,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'ImportImageController', controller: 'ImportImageController',
}, },
}, },
data: {
docs: '/user/docker/images/import',
},
}; };
var networks = { var networks = {
@@ -273,6 +282,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'CreateNetworkController', controller: 'CreateNetworkController',
}, },
}, },
data: {
docs: '/user/docker/networks/add',
},
}; };
var nodes = { var nodes = {
@@ -280,7 +292,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/nodes', url: '/nodes',
abstract: true, abstract: true,
data: { data: {
docs: '/user/docker/swarm', docs: '/user/docker/swarm/details',
}, },
}; };
@@ -338,6 +350,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'CreateSecretController', controller: 'CreateSecretController',
}, },
}, },
data: {
docs: '/user/docker/secrets/add',
},
}; };
var services = { var services = {
@@ -374,6 +389,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'CreateServiceController', controller: 'CreateServiceController',
}, },
}, },
data: {
docs: '/user/docker/stacks/add',
},
}; };
var serviceLogs = { var serviceLogs = {
@@ -444,7 +462,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
}, },
}, },
data: { data: {
docs: '/user/docker/swarm', docs: '/user/docker/swarm/details',
}, },
}; };
@@ -500,7 +518,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
}, },
}, },
data: { data: {
docs: '/user/docker/templates', docs: '/user/docker/templates/application',
}, },
}; };
@@ -549,6 +567,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
controller: 'CreateVolumeController', controller: 'CreateVolumeController',
}, },
}, },
data: {
docs: '/user/docker/volumes/add',
},
}; };
const dockerFeaturesConfiguration = { const dockerFeaturesConfiguration = {
+1 -1
View File
@@ -16,7 +16,7 @@ function ImageHelperFactory() {
/** /**
* *
* @param {import('@/react/docker/images/queries/useImages').ImagesListResponse[]} images * @param {Array<{tags: Array<string>; id: string;}>} images
* @returns {{names: string[]}}} * @returns {{names: string[]}}}
*/ */
function getImagesNamesForDownload(images) { function getImagesNamesForDownload(images) {
+1 -1
View File
@@ -14,7 +14,7 @@ export function ImageViewModel(data) {
} }
} }
this.VirtualSize = data.VirtualSize; this.Size = data.Size;
this.Used = data.Used; this.Used = data.Used;
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) { if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
+13 -6
View File
@@ -6,15 +6,22 @@ export function ImageDetailsViewModel(data) {
this.Created = data.Created; this.Created = data.Created;
this.Checked = false; this.Checked = false;
this.RepoTags = data.RepoTags; this.RepoTags = data.RepoTags;
this.VirtualSize = data.VirtualSize; this.Size = data.Size;
this.DockerVersion = data.DockerVersion; this.DockerVersion = data.DockerVersion;
this.Os = data.Os; this.Os = data.Os;
this.Architecture = data.Architecture; this.Architecture = data.Architecture;
this.Author = data.Author; this.Author = data.Author;
this.Command = data.Config.Cmd; this.Command = data.Config.Cmd;
this.Entrypoint = data.ContainerConfig.Entrypoint ? data.ContainerConfig.Entrypoint : '';
this.ExposedPorts = data.ContainerConfig.ExposedPorts ? Object.keys(data.ContainerConfig.ExposedPorts) : []; let config = {};
this.Volumes = data.ContainerConfig.Volumes ? Object.keys(data.ContainerConfig.Volumes) : []; if (data.Config) {
this.Env = data.ContainerConfig.Env ? data.ContainerConfig.Env : []; config = data.Config; // this is part of OCI images-spec
this.Labels = data.ContainerConfig.Labels; } else if (data.ContainerConfig != null) {
config = data.ContainerConfig; // not OCI ; has been removed in Docker 26 (API v1.45) along with .Container
}
this.Entrypoint = config.Entrypoint ? config.Entrypoint : '';
this.ExposedPorts = config.ExposedPorts ? Object.keys(config.ExposedPorts) : [];
this.Volumes = config.Volumes ? Object.keys(config.Volumes) : [];
this.Env = config.Env ? config.Env : [];
this.Labels = config.Labels;
} }
+6 -2
View File
@@ -79,7 +79,7 @@ const ngModule = angular
) )
.component( .component(
'dockerConfigsDatatable', 'dockerConfigsDatatable',
r2a(withUIRouter(ConfigsDatatable), [ r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
'dataset', 'dataset',
'onRemoveClick', 'onRemoveClick',
'onRefresh', 'onRefresh',
@@ -121,7 +121,11 @@ const ngModule = angular
.component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset'])) .component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset']))
.component( .component(
'dockerSecretsDatatable', 'dockerSecretsDatatable',
r2a(withUIRouter(SecretsDatatable), ['dataset', 'onRefresh', 'onRemove']) r2a(withUIRouter(withCurrentUser(SecretsDatatable)), [
'dataset',
'onRefresh',
'onRemove',
])
) )
.component( .component(
'dockerStacksDatatable', 'dockerStacksDatatable',
+3
View File
@@ -85,6 +85,9 @@ function config($stateRegistryProvider: StateRegistry) {
component: 'createContainerView', component: 'createContainerView',
}, },
}, },
data: {
docs: '/user/docker/containers/add',
},
}); });
$stateRegistryProvider.register({ $stateRegistryProvider.register({
+5
View File
@@ -171,6 +171,11 @@ angular.module('portainer.docker').factory('ImageService', [
return Image.tag({ id: id, repo: image }).$promise; return Image.tag({ id: id, repo: image }).$promise;
}; };
/**
*
* @param {Array<{tags: Array<string>; id: string;}>} images
* @returns {Promise<unknown>}
*/
service.downloadImages = function (images) { service.downloadImages = function (images) {
var names = ImageHelper.getImagesNamesForDownload(images); var names = ImageHelper.getImagesNamesForDownload(images);
return Image.download(names).$promise; return Image.download(names).$promise;
@@ -69,10 +69,10 @@
</div> </div>
</div> </div>
<div ng-if="state !== states.disconnected"> <div ng-if="state !== states.disconnected">
<label class="control-label text-left" <label
>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command >Exec into container as <code class="!text-sm align-baseline">{{ ::formValues.user || 'default user' }}</code> using command
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code> <code class="!text-sm align-baseline">{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
<terminal-tooltip> </terminal-tooltip> <terminal-tooltip class="align-sub"> </terminal-tooltip>
</label> </label>
<button type="button" class="btn btn-primary" ng-click="disconnect()"> <button type="button" class="btn btn-primary" ng-click="disconnect()">
<span ng-show="state === states.connected">Disconnect</span> <span ng-show="state === states.connected">Disconnect</span>
+1 -1
View File
@@ -18,7 +18,7 @@
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'"> <p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon> <pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look at Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look at
<a href="https://docs.portainer.io/admin/environments/add/swarm/agent" target="_blank">our agent setup</a> for more details. <help-link doc-link="'/admin/environments/add/swarm/agent'" target="'_blank'" children="'our agent setup'"></help-link> for more details.
</p> </p>
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'"> <p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon> <pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
@@ -137,5 +137,5 @@ angular.module('portainer.docker').controller('DashboardController', [
]); ]);
function imagesTotalSize(images) { function imagesTotalSize(images) {
return images.reduce((acc, image) => acc + image.VirtualSize, 0); return images.reduce((acc, image) => acc + image.Size, 0);
} }
@@ -209,7 +209,7 @@
</uib-tab> </uib-tab>
<uib-tab index="1" disable="!buildLogs"> <uib-tab index="1" disable="!buildLogs">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading> <uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading>
<pre class="log_viewer"> <pre class="log_viewer" data-cy="logViewer">
<div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div> <div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div>
<div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div> <div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div>
</pre> </pre>
+1 -1
View File
@@ -130,7 +130,7 @@
</tr> </tr>
<tr> <tr>
<td>Size</td> <td>Size</td>
<td>{{ image.VirtualSize | humansize }}</td> <td>{{ image.Size | humansize }}</td>
</tr> </tr>
<tr> <tr>
<td>Created</td> <td>Created</td>
@@ -162,7 +162,7 @@ angular.module('portainer.docker').controller('ImageController', [
function exportImage(image) { function exportImage(image) {
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName); HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName);
$scope.state.exportInProgress = true; $scope.state.exportInProgress = true;
ImageService.downloadImages([image]) ImageService.downloadImages([{ tags: image.RepoTags, id: image.Id }])
.then(function success(data) { .then(function success(data) {
var downloadData = new Blob([data.file], { type: 'application/x-tar' }); var downloadData = new Blob([data.file], { type: 'application/x-tar' });
FileSaver.saveAs(downloadData, 'images.tar'); FileSaver.saveAs(downloadData, 'images.tar');
+10 -3
View File
@@ -1,5 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import { AccessHeaders } from '@/portainer/authorization-guard';
import edgeStackModule from './views/edge-stacks'; import edgeStackModule from './views/edge-stacks';
import { reactModule } from './react'; import { reactModule } from './react';
@@ -12,6 +13,9 @@ angular
url: '/edge', url: '/edge',
parent: 'root', parent: 'root',
abstract: true, abstract: true,
data: {
access: AccessHeaders.EdgeAdmin,
},
}; };
const groups = { const groups = {
@@ -62,12 +66,15 @@ angular
const stacksNew = { const stacksNew = {
name: 'edge.stacks.new', name: 'edge.stacks.new',
url: '/new?templateId', url: '/new?templateId&templateType',
views: { views: {
'content@': { 'content@': {
component: 'createEdgeStackView', component: 'createEdgeStackView',
}, },
}, },
data: {
docs: '/user/edge/stacks/add',
},
}; };
const stacksEdit = { const stacksEdit = {
@@ -137,7 +144,7 @@ angular
}, },
}, },
data: { data: {
docs: '/user/edge/devices', docs: '/user/edge/waiting-room',
}, },
}); });
} }
@@ -151,7 +158,7 @@ angular
}, },
}, },
data: { data: {
docs: '/user/edge/templates', docs: '/user/edge/templates/application',
}, },
}); });
+1 -1
View File
@@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable'; import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset'; import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
const ngModule = angular const ngModule = angular
.module('portainer.edge.react.components', []) .module('portainer.edge.react.components', [])
@@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types';
import { applySetStateAction } from '@/react-tools/apply-set-state-action'; import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset'; import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
export default class CreateEdgeStackViewController { export default class CreateEdgeStackViewController {
/* @ngInject */ /* @ngInject */
@@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController {
} }
/** /**
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction * @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
*/ */
setTemplateValues(templateAction) { setTemplateValues(templateAction) {
return this.$async(async () => { return this.$async(async () => {
@@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController {
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id; const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
this.state.templateValues = newTemplateValues; this.state.templateValues = newTemplateValues;
if (newTemplateId !== oldTemplateId) { if (newTemplateId !== oldTemplateId) {
await this.onChangeTemplate(newTemplateValues.template); await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
} }
let definitions = []; if (newTemplateValues.type === 'custom') {
if (this.state.templateValues.template) { const definitions = this.state.templateValues.template.Variables;
definitions = this.state.templateValues.template.Variables; const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
}
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
this.formValues.StackFileContent = newFile; this.formValues.StackFileContent = newFile;
}
}); });
} }
onChangeTemplate(template) { onChangeTemplate(type, template) {
return this.$async(async () => { return this.$async(async () => {
if (!template) { if (!template) {
return; return;
} }
this.state.templateValues.template = template; if (type === 'custom') {
this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables); this.formValues = {
...this.formValues,
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
...toGitFormModel(template.GitConfig),
...(template.EdgeSettings
? {
PrePullImage: template.EdgeSettings.PrePullImage || false,
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
...template.EdgeSettings.RelativePathSettings,
}
: {}),
};
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig }); const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
this.state.templateValues.file = fileContent; this.state.templateValues.file = fileContent;
}
this.formValues = { if (type === 'app') {
...this.formValues, this.formValues.StackFileContent = '';
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose, try {
...toGitFormModel(template.GitConfig), const fileContent = await fetchFilePreview(template.Id);
...(template.EdgeSettings this.formValues.StackFileContent = fileContent;
? { } catch (err) {
PrePullImage: template.EdgeSettings.PrePullImage || false, this.Notifications.error('Failure', err, 'Unable to retrieve Template');
RetryDeploy: template.EdgeSettings.RetryDeploy || false, }
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null, }
...template.EdgeSettings.RelativePathSettings,
}
: {}),
};
}); });
} }
@@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController {
} }
} }
async preSelectTemplate(templateId) { /**
*
* @param {'app' | 'custom'} templateType
* @param {number} templateId
* @returns {Promise<void>}
*/
async preSelectTemplate(templateType, templateId) {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.state.Method = 'template'; this.state.Method = 'template';
const template = await getCustomTemplate(templateId); const template = await getTemplate(templateType, templateId);
if (!template) {
return;
}
this.setTemplateValues({ template }); this.setTemplateValues({
template,
type: templateType,
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
});
} catch (e) { } catch (e) {
notifyError('Failed loading template', e); notifyError('Failed loading template', e);
} }
@@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
} }
const templateId = this.$state.params.templateId; const templateId = parseInt(this.$state.params.templateId, 10);
if (templateId) { const templateType = this.$state.params.templateType;
this.preSelectTemplate(templateId); if (templateType && templateId && !Number.isNaN(templateId)) {
this.preSelectTemplate(templateType, templateId);
} }
this.$window.onbeforeunload = () => { this.$window.onbeforeunload = () => {
@@ -198,6 +225,16 @@ export default class CreateEdgeStackViewController {
createStack() { createStack() {
return this.$async(async () => { return this.$async(async () => {
const name = this.formValues.Name; const name = this.formValues.Name;
if (!this.validateTemplate()) {
return;
}
let envVars = this.formValues.envVars;
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
}
const method = getMethod(this.state.Method, this.state.templateValues.template); const method = getMethod(this.state.Method, this.state.templateValues.template);
if (!this.validateForm(method)) { if (!this.validateForm(method)) {
@@ -206,7 +243,7 @@ export default class CreateEdgeStackViewController {
this.state.actionInProgress = true; this.state.actionInProgress = true;
try { try {
await this.createStackByMethod(name, method); await this.createStackByMethod(name, method, envVars);
this.Notifications.success('Success', 'Stack successfully deployed'); this.Notifications.success('Success', 'Stack successfully deployed');
this.state.isEditorDirty = false; this.state.isEditorDirty = false;
@@ -258,19 +295,19 @@ export default class CreateEdgeStackViewController {
return true; return true;
} }
createStackByMethod(name, method) { createStackByMethod(name, method, envVars) {
switch (method) { switch (method) {
case 'editor': case 'editor':
return this.createStackFromFileContent(name); return this.createStackFromFileContent(name, envVars);
case 'upload': case 'upload':
return this.createStackFromFileUpload(name); return this.createStackFromFileUpload(name, envVars);
case 'repository': case 'repository':
return this.createStackFromGitRepository(name); return this.createStackFromGitRepository(name, envVars);
} }
} }
createStackFromFileContent(name) { createStackFromFileContent(name, envVars) {
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
return this.EdgeStackService.createStackFromFileContent({ return this.EdgeStackService.createStackFromFileContent({
name, name,
@@ -282,8 +319,9 @@ export default class CreateEdgeStackViewController {
}); });
} }
createStackFromFileUpload(name) { createStackFromFileUpload(name, envVars) {
const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
return this.EdgeStackService.createStackFromFileUpload( return this.EdgeStackService.createStackFromFileUpload(
{ {
Name: name, Name: name,
@@ -296,8 +334,9 @@ export default class CreateEdgeStackViewController {
); );
} }
createStackFromGitRepository(name) { async createStackFromGitRepository(name, envVars) {
const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues; const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
const repositoryOptions = { const repositoryOptions = {
RepositoryURL: this.formValues.RepositoryURL, RepositoryURL: this.formValues.RepositoryURL,
RepositoryReferenceName: this.formValues.RepositoryReferenceName, RepositoryReferenceName: this.formValues.RepositoryReferenceName,
@@ -306,6 +345,7 @@ export default class CreateEdgeStackViewController {
RepositoryUsername: this.formValues.RepositoryUsername, RepositoryUsername: this.formValues.RepositoryUsername,
RepositoryPassword: this.formValues.RepositoryPassword, RepositoryPassword: this.formValues.RepositoryPassword,
TLSSkipVerify: this.formValues.TLSSkipVerify, TLSSkipVerify: this.formValues.TLSSkipVerify,
CreatedFromCustomTemplateID: this.state.templateValues.template && this.state.templateValues.template.Id,
}; };
return this.EdgeStackService.createStackFromGitRepository( return this.EdgeStackService.createStackFromGitRepository(
{ {
@@ -328,12 +368,26 @@ export default class CreateEdgeStackViewController {
}); });
} }
validateTemplate() {
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
return Object.entries(this.state.templateValues.envVars).every(([, value]) => !!value);
}
if (this.state.Method === 'template' && this.state.templateValues.type === 'custom') {
return Object.entries(this.state.templateValues.variables).every(([, v]) => {
return !!v.value;
});
}
return true;
}
formIsInvalid() { formIsInvalid() {
return ( return (
this.form.$invalid || this.form.$invalid ||
!this.formValues.Groups.length || !this.formValues.Groups.length ||
(['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent) || (['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent) ||
('upload' === this.state.Method && !this.formValues.StackFile) ('upload' === this.state.Method && !this.formValues.StackFile) ||
!this.validateTemplate()
); );
} }
} }
@@ -354,3 +408,25 @@ function getMethod(method, template) {
} }
return 'editor'; return 'editor';
} }
/**
*
* @param {'app' | 'custom'} templateType
* @param {number} templateId
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
*/
async function getTemplate(templateType, templateId) {
if (!['app', 'custom'].includes(templateType)) {
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
return;
}
if (templateType === 'app') {
const templatesResponse = await getAppTemplates();
const template = templatesResponse.templates.find((t) => t.id === templateId);
return new TemplateViewModel(template, templatesResponse.version);
}
const template = await getCustomTemplate(templateId);
return template;
}
@@ -1,4 +1,4 @@
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset'; import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods'; import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
class DockerComposeFormController { class DockerComposeFormController {
@@ -35,6 +35,7 @@
on-change="($ctrl.onChangeFormValues)" on-change="($ctrl.onChangeFormValues)"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}" base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
webhook-id="{{ $ctrl.state.webhookId }}" webhook-id="{{ $ctrl.state.webhookId }}"
created-from-custom-template-id="($ctrl.state.templateValues.type === 'custom' ? $ctrl.state.templateValues.template.Id : 0)"
docs-links docs-links
></git-form> ></git-form>
</div> </div>
+37 -4
View File
@@ -140,7 +140,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}, },
}, },
data: { data: {
docs: '/user/kubernetes/helm', docs: '/user/kubernetes/inspect-helm',
}, },
}; };
@@ -153,7 +153,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}, },
}, },
data: { data: {
docs: '/user/kubernetes/services', docs: '/user/kubernetes/networking/services',
}, },
}; };
@@ -166,7 +166,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}, },
}, },
data: { data: {
docs: '/user/kubernetes/ingresses', docs: '/user/kubernetes/networking/ingresses',
}, },
}; };
@@ -178,6 +178,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesIngressesCreateView', component: 'kubernetesIngressesCreateView',
}, },
}, },
data: {
docs: '/user/kubernetes/networking/ingresses/add',
},
}; };
const ingressesEdit = { const ingressesEdit = {
@@ -211,6 +214,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesCreateApplicationView', component: 'kubernetesCreateApplicationView',
}, },
}, },
data: {
docs: '/user/kubernetes/applications/add',
},
}; };
const application = { const application = {
@@ -221,6 +227,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'applicationDetailsView', component: 'applicationDetailsView',
}, },
}, },
data: {
docs: '/user/kubernetes/applications/inspect',
},
}; };
const applicationEdit = { const applicationEdit = {
@@ -231,6 +240,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesCreateApplicationView', component: 'kubernetesCreateApplicationView',
}, },
}, },
data: {
docs: '/user/kubernetes/applications/edit',
},
}; };
const applicationConsole = { const applicationConsole = {
@@ -317,6 +329,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesCreateConfigMapView', component: 'kubernetesCreateConfigMapView',
}, },
}, },
data: {
docs: '/user/kubernetes/configurations/add-configmap',
},
}; };
const configMap = { const configMap = {
@@ -346,6 +361,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesCreateSecretView', component: 'kubernetesCreateSecretView',
}, },
}, },
data: {
docs: '/user/kubernetes/configurations/add-secret',
},
}; };
const secret = { const secret = {
@@ -367,7 +385,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}, },
}, },
data: { data: {
docs: '/user/kubernetes/cluster', docs: '/user/kubernetes/cluster/details',
}, },
}; };
@@ -379,6 +397,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesNodeView', component: 'kubernetesNodeView',
}, },
}, },
data: {
docs: '/user/kubernetes/cluster/node',
},
}; };
const nodeStats = { const nodeStats = {
@@ -412,6 +433,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesDeployView', component: 'kubernetesDeployView',
}, },
}, },
data: {
docs: '/user/kubernetes/applications/manifest',
},
}; };
const resourcePools = { const resourcePools = {
@@ -435,6 +459,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesCreateNamespaceView', component: 'kubernetesCreateNamespaceView',
}, },
}, },
data: {
docs: '/user/kubernetes/namespaces/add',
},
}; };
const resourcePool = { const resourcePool = {
@@ -445,6 +472,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesResourcePoolView', component: 'kubernetesResourcePoolView',
}, },
}, },
data: {
docs: '/user/kubernetes/namespaces/manage',
},
}; };
const resourcePoolAccess = { const resourcePoolAccess = {
@@ -455,6 +485,9 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
component: 'kubernetesResourcePoolAccessView', component: 'kubernetesResourcePoolAccessView',
}, },
}, },
data: {
docs: '/user/kubernetes/namespaces/access',
},
}; };
const volumes = { const volumes = {

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