Compare commits

..

112 Commits

Author SHA1 Message Date
LP B
ac5491e864 feat(app): limit the docker API version supported by the frontend (#12295) 2024-10-08 17:13:14 +02:00
Oscar Zhou
8cbd23c059 version: bump version to 2.21.3 (#12300) 2024-10-08 08:12:46 +13:00
andres-portainer
3800a958da fix(endpoints): optimize the search performance BE-11267 (#12264) 2024-10-01 15:14:04 -03:00
Oscar Zhou
09348b8a25 version: bump version to 2.21.2 (#12244) 2024-09-24 07:54:26 +12:00
Nik Wakelin
1ef9c249b7 chore(branding): Backport branding changes to 2 21 (#12243) 2024-09-23 10:33:55 +12:00
Oscar Zhou
33ac61c600 fix: golang lint error [BE-11235] (#12215) 2024-09-17 08:08:26 +12:00
Oscar Zhou
bdb84617fe chore(version): bump version to 2.21.1 (#12203) 2024-09-09 09:39:18 -03:00
andres-portainer
2d5c834590 fix(users): fix data-race in userCreate() BE-11209 (#12194) 2024-09-05 22:28:11 -03:00
andres-portainer
280ca22aeb fix(teams): fix data-race in teamCreate() BE-11210 (#12196) 2024-09-05 21:36:26 -03:00
Oscar Zhou
753150e03c fix(stack): env placeholder as host path [BE-11187] (#12186) 2024-09-06 08:42:55 +12:00
Yajith Dayarathna
517abc662a update ci workflow (release/2.21) (#12184) 2024-09-05 09:19:20 +12:00
andres-portainer
04e9ee3b3e fix(docker): avoid specifying the MAC address of container for Docker API < v1.44 BE-10880 (#12178) 2024-09-03 10:31:19 -03:00
andres-portainer
273ea5df23 fix(jwt): generate JWT IDs BE-11179 (#12176) 2024-09-02 12:06:44 -03:00
andres-portainer
6cc95e11ae fix(bouncer): add support for JWT revocation BE-11179 (#12165) 2024-08-30 20:24:14 -03:00
andres-portainer
9133cbf544 fix(git): optimize listFiles() BE-11184 (#12161) 2024-08-29 19:07:17 -03:00
Anthony Lapenna
111f641979 security: bump dependencies to address CVEs (#12118) 2024-08-21 20:08:23 +12:00
Ali
da370316df fix(docker-desktop): support auth cookies [BE-11134] (#12109) 2024-08-21 18:21:54 +12:00
LP B
f69825d859 fix(api/edge_stacks): ensure edge stacks related endpoints list generation returns unique elements (#12102) 2024-08-20 10:20:07 +02:00
deviantony
9fd5669a23 version: revert bump to rc1 2024-08-14 18:50:52 +00:00
deviantony
efc2bf9292 version: bump version to 2.21.0-rc1 2024-08-14 18:40:52 +00:00
Oscar Zhou
4d74a00492 fix(group): create group twice when associating devices [EE-7418] (#12091) 2024-08-12 17:09:44 +12:00
LP B
68011fb293 fix(app/registries): enforce user accesses on registries (#12088) 2024-08-10 11:53:19 +02:00
andres-portainer
25f84c0b3e fix(compose): avoid the need to pass the file to remove the stack BE-11057 (#12064)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
2024-08-09 10:59:10 -03:00
Oscar Zhou
13766cc465 fix(stack/remote): pass forceRecreate setting [EE-7374] (#12050) 2024-08-06 09:02:12 +12:00
Yajith Dayarathna
5e6e3048d5 Installing docker-compose during test-server step - 2.21 (#12076) 2024-08-05 11:29:01 +12:00
andres-portainer
18e755e30e fix(pendingactions): remove excessive logging BE-11094 (#12070) 2024-08-02 16:35:08 -03:00
Oscar Zhou
a63bd2cea4 fix(table): delete item doesn't refresh the table [BE-11064] (#12060) 2024-07-31 20:39:06 +12:00
andres-portainer
ef8e611e0a fix(snapshots): remove the attempt to snapshot untrusted environments EE-7407 (#12045) 2024-07-23 18:43:26 -03:00
Ali
cc60836bb8 fix(placements) filter out empty items in the required node affinity array [BE-11022] (#12036)
Co-authored-by: testa113 <testa113>
2024-07-23 09:31:13 +12:00
LP B
e2830019d7 fix(docker/container): use nodeName to build links to networks used by containers (#12004) 2024-07-17 14:40:10 +02:00
Oscar Zhou
20de243299 fix(host): show clear host info message [EE-7075] (#12010) 2024-07-12 08:45:44 +12:00
Oscar Zhou
e8af981746 fix(stack): excessive alias count error [EE-7305] (#11991) 2024-07-11 14:09:46 +12:00
andres-portainer
fa4711946d fix(snapshots): fix background snapshots on environment creation EE-7273 (#12022) 2024-07-09 15:18:17 -03:00
LP B
566e37535f fix(docker/network): send target nodeName when removing a network on swarm (#12000) 2024-07-08 17:31:27 +02:00
Steven Kang
3529a36f92 fix(cve): remediate cves detected in docker scout (#12012) 2024-07-08 10:24:36 +12:00
andres-portainer
9c775396fd fix(kube): improve error handling EE-7196 (#11977) 2024-06-27 10:45:16 -03:00
andres-portainer
65871207f0 fix(kube): improve error handling EE-7199 (#11975) 2024-06-27 10:43:49 -03:00
andres-portainer
dc3e20acac fix(snapshots): enable the background snapshotter EE-7273 (#11972) 2024-06-26 18:27:44 -03:00
cmeng
91a477d9fb fix(host-info) host info improvement EE-7075 (#11883) 2024-06-26 12:18:29 -03:00
LP B
9339d10233 fix(app): properly update the app state when losing connectivity to a remote environment while browsing it (#11943) 2024-06-19 13:45:02 +02:00
Ali
e7af3296fc fix(custom-templates): relax custom template validation and enforce stack name validation [EE-7102] (#11937)
Co-authored-by: testa113 <testa113>
2024-06-17 09:24:50 +12:00
Chaim Lev-Ari
5182220d0a fix(edge/update): show environment count when more than 100 [EE-6424] (#11916) 2024-06-14 18:37:41 -03:00
Chaim Lev-Ari
9d7173fb5f fix(endpoints): show toaster on delete [EE-7170] (#11888) 2024-06-13 18:32:23 -03:00
Ali
69f9a509c8 fix(namespace): sanitize owner label [EE-7122] (#11936)
Co-authored-by: testa113 <testa113>
2024-06-13 11:06:08 +12:00
James Carppe
93ce33fac8 Add support for specifying the NFS server address in the mount point EE-7019 (#11922) 2024-06-12 11:23:21 -03:00
Dakota Walsh
55706cbf35 fix(kubernetes): cluster setup screen text on own line EE-7112 (#11904) 2024-06-12 08:43:13 +12:00
Oscar Zhou
e0934bb7fa fix(customtemplate): duplicated error handling [EE-7197] (#11912) 2024-06-11 22:11:08 +12:00
Chaim Lev-Ari
c4235c84a7 feat(edge/stacks): default refresh rate to 10s [EE-7155] (#11890) 2024-06-09 14:17:16 +03:00
Matt Hook
4fd4aa8138 Revert "feat(dashboard): dashboard api [EE-7111]" (#11906) 2024-06-07 14:08:08 +12:00
Matt Hook
8aec5adb66 fix(db): fix missing portainer.edb in backups when encrypted portainer db is used [EE-6417] (#11886) 2024-06-06 12:37:08 +12:00
Matt Hook
d490061c1f fix(pendingactions): fix deadlock and improve debug logging [EE-7049] (#11868) 2024-05-30 14:55:09 +12:00
Oscar Zhou
b23b0f7c8d fix(compose): add project directory option to compose command [EE-7093] (#11859) 2024-05-30 08:46:48 +12:00
matias-portainer
c94cfb1308 fix(waiting-room): add support for bulk deletion in waiting room EE-7136 (#11880) 2024-05-28 17:18:20 -03:00
andres-portainer
4be2c061f5 fix(tunnels): make the tunnels more robust EE-7042 (#11878) 2024-05-28 16:43:01 -03:00
andres-portainer
0356288fd9 fix(tls): add support for more cipher suites EE-7150 (#11875) 2024-05-28 15:49:21 -03:00
andres-portainer
3b95c333fc task(endpoints): change the definition of /endpoints/remove EE-7126 (#11872) 2024-05-28 09:05:26 -03:00
cmeng
11404aaecb fix(deletion): delete registries batch by batch EE-7084 (#11856) 2024-05-24 14:30:36 +12:00
Oscar Zhou
ccb6dd7f1a fix(api/docker): no authorized user can call restricted api [EE-6808] (#11478) 2024-05-22 09:08:51 +12:00
Matt Hook
6e0dd34cc8 feat(dashboard): dashboard api [EE-7111] (#11844) 2024-05-21 11:09:34 +12:00
Oscar Zhou
61ef133bb8 fix(edge/stack): edge stack env table pagination and action [EE-6836] (#11836) 2024-05-21 09:40:03 +12:00
Matt Hook
f5d896bce1 fix(console): fix command not found [EE-6982] (#11832) 2024-05-20 14:35:40 +12:00
andres-portainer
6c98271e43 fix(endpoints): remove all the endpoints in the same transaction EE-7095 (#11840) 2024-05-17 16:45:12 -03:00
cmeng
4700e38e5d fix(deletion): delete objects batch by batch EE-7084 (#11834) 2024-05-16 14:34:47 +12:00
Matt Hook
2f1b5ec979 fix(pending-actions): correctly detect unreachable/down cluster [EE-7049] (#11811) 2024-05-16 09:02:47 +12:00
Ali
39088b16b3 fix(app): ensure placement errors surface per node [EE-7065] (#11822)
Co-authored-by: testa113 <testa113>
2024-05-14 15:03:12 +12:00
Oscar Zhou
680cb3b36a fix(image): github registry image truncated [EE-7021] (#11768) 2024-05-10 09:01:47 +12:00
Oscar Zhou
1fce2b83d7 fix(api): list docker volume performance [EE-6896] (#11540) 2024-05-09 13:02:49 +12:00
Dakota Walsh
92c8692bbe fix(metadata): add mutli endpoint delete api EE-6872 (#11789) 2024-05-08 17:09:03 -04:00
Matt Hook
65060725df fix(pending-action): pending action data format [EE-7064] (#11794)
Co-authored-by: Prabhat Khera <91852476+prabhat-portainer@users.noreply.github.com>
2024-05-09 08:15:37 +12:00
cmeng
b920c542dd fix(docker): keep /docker url prefix for DockerHandler EE-7073 (#11800) 2024-05-08 14:26:36 +12:00
Ali
be99f646f8 fix(auth logs): fix typo in search keyword [EE-6742] (#11792)
Co-authored-by: testa113 <testa113>
2024-05-08 09:16:09 +12:00
Ali
6d2775a42e fix(be-overlay): consistency overlay with variants [EE-6742] (#11776)
Co-authored-by: testa113 <testa113>
2024-05-07 16:16:55 +12:00
Ali
4bcefb4866 fix(app): show one tooltip to describe rollback feature [EE-6825] (#11779)
Co-authored-by: testa113 <testa113>
2024-05-07 15:27:28 +12:00
cmeng
f16d2e5d28 fix(container): specify node name when get a container EE-6981 (#11749) 2024-05-07 11:34:42 +12:00
Steven Kang
9960137a7c fix: windows container capability [EE-5814] (#11763) 2024-05-03 10:56:31 +12:00
Ali
62c625c446 fix(stacks): hide stack cols [EE-6949] (#11753) 2024-05-03 09:12:39 +12:00
Matt Hook
acaa564557 fix(kube): correctly extract namespace from namespace manifest [EE-6555] (#11675)
Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2024-05-02 14:28:07 +12:00
Ali
d727cbc373 fix(app): explain rollback tooltip [EE-6825] (#11700)
Co-authored-by: testa113 <testa113>
2024-05-02 14:10:30 +12:00
Dakota Walsh
31403bfc7e fix(migration): improper version EE-7048 (#11713) 2024-04-30 21:30:46 -04:00
Matt Hook
cf91a8352d fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11681) 2024-04-30 19:17:22 +12:00
cmeng
549579d935 fix(edge-stack): add completed status EE-6210 (#11635) 2024-04-30 13:44:14 +12:00
Ali
0be1888f11 fix(version): reduce github requests [EE-7017] (#11679) 2024-04-26 08:46:13 +12:00
Ali
2856d0ed64 fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11673) 2024-04-26 08:42:17 +12:00
Chaim Lev-Ari
d70812be0d fix(users): return json from create token [EE-6856] (#11578) 2024-04-25 10:10:45 +03:00
Ali
efc88c0073 fix(migration): run post init migrations for edge after server starts [EE-6905] (#11548)
Co-authored-by: testa113 <testa113>
2024-04-23 16:15:23 +12:00
Matt Hook
00d8391a02 fix(published-ports): fix published port link and into a new component [EE-6592] (#11657) 2024-04-23 13:47:51 +12:00
Matt Hook
4403503d82 fix(settings): fix crash during settings update when not using oauth [EE-7031] (#11661) 2024-04-23 12:58:21 +12:00
Prabhat Khera
8ae64523b3 fix(stack): correct documentation link for stack ENV variables [EE-6902] (#11653) 2024-04-23 08:35:25 +12:00
Oscar Zhou
ccfd5e4500 feat(setting/oauth): add authstyle option [EE-6038] (#11590) 2024-04-22 10:35:26 +12:00
Oscar Zhou
a755e6be15 fix(stack/git): option to overwrite target path during dir move [EE-6871] (#11627) 2024-04-22 10:34:38 +12:00
cmeng
17561c1c0c fix(docker-client): explicitly set docker client scheme EE-6935 (#11519) 2024-04-22 09:00:48 +12:00
Ali
d73b162d16 fix(stacks): conditionally hide node and namespace stacks [EE-6949] (#11528) 2024-04-19 17:33:26 +12:00
Prabhat Khera
d5c4671320 fix(swagger): swagger docs for http status code 409 [EE-5767] (#11536) 2024-04-19 15:19:10 +12:00
Matt Hook
f9065367b9 chore(kubectl): update kubectl to latest point release [EE-7018] (#11622) 2024-04-19 11:47:19 +12:00
andres-portainer
218b8bf300 fix(workflows): upgrade Go to v1.21.9 EE-6939 (#11642) 2024-04-18 19:03:17 -03:00
Prabhat Khera
aa9e73002f fix(stack): fix stack env variable link [EE-6902] (#11626) 2024-04-19 07:00:15 +12:00
andres-portainer
b86b721b0f fix(mingit): upgrade to v2.44.0.1 EE-7023 (#11639) 2024-04-18 15:22:15 -03:00
Prabhat Khera
95292d20f4 fix(images): consider stopped containers for unused label [EE-6983] (#11631) 2024-04-18 17:15:03 +12:00
andres-portainer
fb7c23a241 fix(docker): upgrade to v24.0.9 EE-7016 (#11618) 2024-04-17 19:38:11 -03:00
andres-portainer
a3146fff36 fix(go): upgrade Go to v1.21.9 in the nightly security scan EE-6939 (#11615) 2024-04-17 18:09:47 -03:00
Matt Hook
483aa80e40 fix(auth): prevent user enumeration attack [EE-6832] (#11588) 2024-04-17 16:08:48 +12:00
Prabhat Khera
fb4ffaec35 fix(pending-actions): clean pending actions for deleted environment [EE-6545] (#11600) 2024-04-17 08:32:32 +12:00
Oscar Zhou
1a801d86f0 fix(api/endpoint): filter status for async devices [EE-6958] (#11508) 2024-04-16 13:36:55 +12:00
Matt Hook
fe41d23244 chore(docker): bump docker client to 26.0.1 [EE-6941] (#11593) 2024-04-16 08:27:48 +12:00
Prabhat Khera
0e163adf8d fix(stacks): update info text for stack environment variables [EE-6902] (#11558) 2024-04-16 08:03:51 +12:00
Prabhat Khera
eb4a06d422 fix(pending-actions): fix create kubeclient to check endpoint status [EE-6545] (#11586) 2024-04-16 07:40:49 +12:00
Matt Hook
1fee55ddd2 chore(api): bump docker and protobuf pkgs [EE-6941] (#11565) 2024-04-15 10:53:07 +12:00
Prabhat Khera
2cbc5027aa chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11497)
Co-authored-by: hookenz <hookenz@gmail.com>
2024-04-15 10:30:01 +12:00
Prabhat Khera
305a7a354a chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11507) 2024-04-15 10:29:17 +12:00
Matt Hook
948e2bb715 bump helm version (#11564) 2024-04-15 09:17:09 +12:00
andres-portainer
02b6829819 fix(protobuf): upgrade protobuf to v1.33 EE-6945 (#11569) 2024-04-12 17:52:43 -03:00
andres-portainer
3d3d65d44e fix(go): upgrade Go to v1.21.9 EE-6939 (#11555) 2024-04-12 17:08:15 -03:00
482 changed files with 9396 additions and 5579 deletions

View File

@@ -22,7 +22,7 @@ on:
env:
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.6
GO_VERSION: 1.21.11
NODE_VERSION: 18.x
jobs:
@@ -34,7 +34,6 @@ jobs:
- { 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: ltsc2022 }
runs-on: ubuntu-latest
@@ -146,31 +145,22 @@ jobs:
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi
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-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}-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"
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le-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"
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64"
fi

View File

@@ -18,7 +18,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -6,7 +6,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
jobs:
client-dependencies:

View File

@@ -14,7 +14,7 @@ on:
- '.github/workflows/pr-security.yml'
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -1,10 +1,11 @@
name: Test
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
on:
workflow_dispatch:
pull_request:
branches:
- master
@@ -27,15 +28,22 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up node.js'
uses: actions/setup-node@v4.0.1
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- run: yarn --frozen-lockfile
- name: Run tests
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
test-server:
strategy:
matrix:
@@ -48,9 +56,21 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up golang'
uses: actions/setup-go@v5.0.0
with:
go-version: ${{ env.GO_VERSION }}
- name: Run tests
- name: 'install dependencies'
run: make test-deps PLATFORM=linux ARCH=amd64
- name: 'update $PATH'
run: echo "$(pwd)/dist" >> $GITHUB_PATH
- name: 'run tests'
run: make test-server

View File

@@ -13,7 +13,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
initMSW(
{
onUnhandledRequest: ({ method, url }) => {
console.log(method, url);
if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.

View File

@@ -64,6 +64,9 @@ clean: ## Remove all build and download artifacts
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-deps: init-dist
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
test-client: ## Run client tests
yarn test $(ARGS)

View File

@@ -82,7 +82,8 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
return err
}

View File

@@ -5,6 +5,17 @@ import (
"github.com/portainer/portainer/api/internal/edge/cache"
)
// EdgeJobs retrieves the edge jobs for the given environment
func (service *Service) EdgeJobs(endpointID portainer.EndpointID) []portainer.EdgeJob {
service.mu.RLock()
defer service.mu.RUnlock()
return append(
make([]portainer.EdgeJob, 0, len(service.edgeJobs[endpointID])),
service.edgeJobs[endpointID]...,
)
}
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint).
func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portainer.EdgeJob) {
if endpoint.Edge.AsyncMode {
@@ -12,10 +23,10 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
service.mu.Lock()
tunnel := service.getTunnelDetails(endpoint.ID)
defer service.mu.Unlock()
existingJobIndex := -1
for idx, existingJob := range tunnel.Jobs {
for idx, existingJob := range service.edgeJobs[endpoint.ID] {
if existingJob.ID == edgeJob.ID {
existingJobIndex = idx
@@ -24,30 +35,28 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
if existingJobIndex == -1 {
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
service.edgeJobs[endpoint.ID] = append(service.edgeJobs[endpoint.ID], *edgeJob)
} else {
tunnel.Jobs[existingJobIndex] = *edgeJob
service.edgeJobs[endpoint.ID][existingJobIndex] = *edgeJob
}
cache.Del(endpoint.ID)
service.mu.Unlock()
}
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
for endpointID, tunnel := range service.tunnelDetailsMap {
for endpointID := range service.edgeJobs {
n := 0
for _, edgeJob := range tunnel.Jobs {
for _, edgeJob := range service.edgeJobs[endpointID] {
if edgeJob.ID != edgeJobID {
tunnel.Jobs[n] = edgeJob
service.edgeJobs[endpointID][n] = edgeJob
n++
}
}
tunnel.Jobs = tunnel.Jobs[:n]
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
cache.Del(endpointID)
}
@@ -57,19 +66,17 @@ func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
func (service *Service) RemoveEdgeJobFromEndpoint(endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
defer service.mu.Unlock()
n := 0
for _, edgeJob := range tunnel.Jobs {
for _, edgeJob := range service.edgeJobs[endpointID] {
if edgeJob.ID != edgeJobID {
tunnel.Jobs[n] = edgeJob
service.edgeJobs[endpointID][n] = edgeJob
n++
}
}
tunnel.Jobs = tunnel.Jobs[:n]
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
cache.Del(endpointID)
service.mu.Unlock()
}

View File

@@ -19,7 +19,6 @@ import (
const (
tunnelCleanupInterval = 10 * time.Second
requiredTimeout = 15 * time.Second
activeTimeout = 4*time.Minute + 30*time.Second
pingTimeout = 3 * time.Second
)
@@ -28,32 +27,54 @@ const (
// It is used to start a reverse tunnel server and to manage the connection status of each tunnel
// connected to the tunnel server.
type Service struct {
serverFingerprint string
serverPort string
tunnelDetailsMap map[portainer.EndpointID]*portainer.TunnelDetails
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.Mutex
fileService portainer.FileService
serverFingerprint string
serverPort string
activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails
edgeJobs map[portainer.EndpointID][]portainer.EdgeJob
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.RWMutex
fileService portainer.FileService
defaultCheckinInterval int
}
// NewService returns a pointer to a new instance of Service
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds
settings, err := dataStore.Settings().Settings()
if err == nil {
defaultCheckinInterval = settings.EdgeAgentCheckinInterval
} else {
log.Error().Err(err).Msg("unable to retrieve the settings from the database")
}
return &Service{
tunnelDetailsMap: make(map[portainer.EndpointID]*portainer.TunnelDetails),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails),
edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
defaultCheckinInterval: defaultCheckinInterval,
}
}
// pingAgent ping the given agent so that the agent can keep the tunnel alive
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
tunnel := service.GetTunnelDetails(endpointID)
requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port)
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnelAddr, err := service.TunnelAddr(endpoint)
if err != nil {
return err
}
requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr)
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
if err != nil {
return err
@@ -76,47 +97,49 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
go func() {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
go service.keepTunnelAlive(endpointID, ctx, maxAlive)
}
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
for {
select {
case <-pingTicker.C:
service.SetTunnelStatusToActive(endpointID)
err := service.pingAgent(endpointID)
if err != nil {
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
return
case <-ctx.Done():
err := ctx.Err()
for {
select {
case <-pingTicker.C:
service.UpdateLastActivity(endpointID)
if err := service.pingAgent(endpointID); err != nil {
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: tunnel stop")
return
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
return
case <-ctx.Done():
err := ctx.Err()
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: tunnel stop")
return
}
}()
}
}
// StartTunnelServer starts a tunnel server on the specified addr and port.
@@ -126,7 +149,6 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
// The snapshotter is used in the tunnel status verification process.
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
privateKeyFile, err := service.retrievePrivateKeyFile()
if err != nil {
return err
}
@@ -144,21 +166,21 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
service.serverFingerprint = chiselServer.GetFingerprint()
service.serverPort = port
err = chiselServer.Start(addr, port)
if err != nil {
if err := chiselServer.Start(addr, port); err != nil {
return err
}
service.chiselServer = chiselServer
// TODO: work-around Chisel default behavior.
// By default, Chisel will allow anyone to connect if no user exists.
username, password := generateRandomCredentials()
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
if err != nil {
if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil {
return err
}
service.snapshotService = snapshotService
go service.startTunnelVerificationLoop()
return nil
@@ -172,37 +194,39 @@ func (service *Service) StopTunnelServer() error {
func (service *Service) retrievePrivateKeyFile() (string, error) {
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
exist, _ := service.fileService.FileExists(privateKeyFile)
if !exist {
log.Debug().
Str("private-key", privateKeyFile).
Msg("Chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("Failed to generate chisel private key")
return "", err
}
err = service.fileService.StoreChiselPrivateKey(privateKey)
if err != nil {
log.Error().
Err(err).
Msg("Failed to save Chisel private key to disk")
return "", err
} else {
log.Info().
Str("private-key", privateKeyFile).
Msg("Generated a new Chisel private key file")
}
} else {
if exists, _ := service.fileService.FileExists(privateKeyFile); exists {
log.Info().
Str("private-key", privateKeyFile).
Msg("Found Chisel private key file on disk")
Msg("found Chisel private key file on disk")
return privateKeyFile, nil
}
log.Debug().
Str("private-key", privateKeyFile).
Msg("chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("failed to generate chisel private key")
return "", err
}
if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil {
log.Error().
Err(err).
Msg("failed to save Chisel private key to disk")
return "", err
}
log.Info().
Str("private-key", privateKeyFile).
Msg("generated a new Chisel private key file")
return privateKeyFile, nil
}
@@ -230,63 +254,45 @@ func (service *Service) startTunnelVerificationLoop() {
}
}
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
func (service *Service) checkTunnels() {
tunnels := make(map[portainer.EndpointID]portainer.TunnelDetails)
service.mu.RLock()
service.mu.Lock()
for key, tunnel := range service.tunnelDetailsMap {
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
continue
}
if tunnel.Status == portainer.EdgeAgentManagementRequired && time.Since(tunnel.LastActivity) < requiredTimeout {
continue
}
if tunnel.Status == portainer.EdgeAgentActive && time.Since(tunnel.LastActivity) < activeTimeout {
continue
}
tunnels[key] = *tunnel
}
service.mu.Unlock()
for endpointID, tunnel := range tunnels {
for endpointID, tunnel := range service.activeTunnels {
elapsed := time.Since(tunnel.LastActivity)
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("last_activity_seconds", elapsed.Seconds()).
Msg("environment tunnel monitoring")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed > requiredTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", requiredTimeout.Seconds()).
Msg("REQUIRED state timeout exceeded")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
continue
}
if tunnel.Status == portainer.EdgeAgentActive && elapsed > activeTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("ACTIVE state timeout exceeded")
tunnelPort := tunnel.Port
err := service.snapshotEnvironment(endpointID, tunnel.Port)
if err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.mu.RUnlock()
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("last_activity_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
service.close(portainer.EndpointID(endpointID))
return
}
service.mu.RUnlock()
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {

View File

@@ -7,14 +7,22 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestPingAgentPanic(t *testing.T) {
endpointID := portainer.EndpointID(1)
endpoint := &portainer.Endpoint{
ID: 1,
EdgeID: "test-edge-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
UserTrusted: true,
}
s := NewService(nil, nil, nil)
_, store := datastore.MustNewTestStore(t, true, true)
s := NewService(store, nil, nil)
defer func() {
require.Nil(t, recover())
@@ -32,8 +40,9 @@ func TestPingAgentPanic(t *testing.T) {
require.NoError(t, http.Serve(ln, mux))
}()
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
err = s.Open(endpoint)
require.NoError(t, err)
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpointID))
require.Error(t, s.pingAgent(endpoint.ID))
}

View File

@@ -5,14 +5,18 @@ import (
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/pkg/libcrypto"
"github.com/dchest/uniuri"
"github.com/rs/zerolog/log"
)
const (
@@ -20,18 +24,191 @@ const (
maxAvailablePort = 65535
)
var (
ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments")
ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments")
ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment")
)
// Open will mark the tunnel as REQUIRED so the agent opens it
func (s *Service) Open(endpoint *portainer.Endpoint) error {
if !endpointutils.IsEdgeEndpoint(endpoint) {
return ErrNonEdgeEnv
}
if endpoint.Edge.AsyncMode {
return ErrAsyncEnv
}
if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted {
return ErrInvalidEnv
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.activeTunnels[endpoint.ID]; ok {
return nil
}
defer cache.Del(endpoint.ID)
tun := &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: s.getUnusedPort(),
LastActivity: time.Now(),
}
username, password := generateRandomCredentials()
if s.chiselServer != nil {
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port)
if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tun.Credentials = credentials
s.activeTunnels[endpoint.ID] = tun
return nil
}
// close removes the tunnel from the map so the agent will close it
func (s *Service) close(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
tun, ok := s.activeTunnels[endpointID]
if !ok {
return
}
if len(tun.Credentials) > 0 && s.chiselServer != nil {
user, _, _ := strings.Cut(tun.Credentials, ":")
s.chiselServer.DeleteUser(user)
}
if s.ProxyManager != nil {
s.ProxyManager.DeleteEndpointProxy(endpointID)
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
}
// Config returns the tunnel details needed for the agent to connect
func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
s.mu.RLock()
defer s.mu.RUnlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
return *tun
}
return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle}
}
// TunnelAddr returns the address of the local tunnel, including the port, it
// will block until the tunnel is ready
func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
if err := s.Open(endpoint); err != nil {
return "", err
}
tun := s.Config(endpoint.ID)
checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second
for t0 := time.Now(); ; {
if time.Since(t0) > 2*checkinInterval {
s.close(endpoint.ID)
return "", errors.New("unable to open the tunnel")
}
// Check if the tunnel is established
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port})
if err != nil {
time.Sleep(checkinInterval / 100)
continue
}
conn.Close()
break
}
s.UpdateLastActivity(endpoint.ID)
return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil
}
// tryEffectiveCheckinInterval avoids a potential deadlock by returning a
// previous known value after a timeout
func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int {
ch := make(chan int, 1)
go func() {
ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint)
}()
select {
case <-time.After(50 * time.Millisecond):
s.mu.RLock()
defer s.mu.RUnlock()
return s.defaultCheckinInterval
case i := <-ch:
s.mu.Lock()
s.defaultCheckinInterval = i
s.mu.Unlock()
return i
}
}
// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout
func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
tun.LastActivity = time.Now()
}
}
// NOTE: it needs to be called with the lock acquired
// getUnusedPort is used to generate an unused random port in the dynamic port range.
// Dynamic ports (also called private ports) are 49152 to 65535.
func (service *Service) getUnusedPort() int {
port := randomInt(minAvailablePort, maxAvailablePort)
for _, tunnel := range service.tunnelDetailsMap {
for _, tunnel := range service.activeTunnels {
if tunnel.Port == port {
return service.getUnusedPort()
}
}
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err == nil {
conn.Close()
log.Debug().
Int("port", port).
Msg("selected port is in use, trying a different one")
return service.getUnusedPort()
}
return port
}
@@ -39,152 +216,10 @@ func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}
// NOTE: it needs to be called with the lock acquired
func (service *Service) getTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
if tunnel, ok := service.tunnelDetailsMap[endpointID]; ok {
return tunnel
}
tunnel := &portainer.TunnelDetails{
Status: portainer.EdgeAgentIdle,
}
service.tunnelDetailsMap[endpointID] = tunnel
cache.Del(endpointID)
return tunnel
}
// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint).
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) portainer.TunnelDetails {
service.mu.Lock()
defer service.mu.Unlock()
return *service.getTunnelDetails(endpointID)
}
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (portainer.TunnelDetails, error) {
if endpoint.Edge.AsyncMode {
return portainer.TunnelDetails{}, errors.New("cannot open tunnel on async endpoint")
}
tunnel := service.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentActive {
// update the LastActivity
service.SetTunnelStatusToActive(endpoint.ID)
}
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
err := service.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
}
if endpoint.EdgeCheckinInterval == 0 {
settings, err := service.dataStore.Settings().Settings()
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed fetching settings from db: %w", err)
}
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
time.Sleep(2 * time.Duration(endpoint.EdgeCheckinInterval) * time.Second)
}
return service.GetTunnelDetails(endpoint.ID), nil
}
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to ACTIVE.
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentActive
tunnel.Credentials = ""
tunnel.LastActivity = time.Now()
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to IDLE.
// It removes any existing credentials associated to the tunnel.
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentIdle
tunnel.Port = 0
tunnel.LastActivity = time.Now()
credentials := tunnel.Credentials
if credentials != "" {
tunnel.Credentials = ""
if service.chiselServer != nil {
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
}
}
service.ProxyManager.DeleteEndpointProxy(endpointID)
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to REQUIRED.
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
// Credentials are encrypted using the Edge ID associated to the environment(endpoint).
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
defer cache.Del(endpointID)
tunnel := service.getTunnelDetails(endpointID)
service.mu.Lock()
defer service.mu.Unlock()
if tunnel.Port == 0 {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnel.Status = portainer.EdgeAgentManagementRequired
tunnel.Port = service.getUnusedPort()
tunnel.LastActivity = time.Now()
username, password := generateRandomCredentials()
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
if service.chiselServer != nil {
err = service.chiselServer.AddUser(username, password, authorizedRemote)
if err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tunnel.Credentials = credentials
}
return nil
}
func generateRandomCredentials() (string, string) {
username := uniuri.NewLen(8)
password := uniuri.NewLen(8)
return username, password
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"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/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -457,19 +458,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
authorizationService := authorization.NewService(dataStore)
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()
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
@@ -489,6 +482,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
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)
if err != nil {
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
// client. Therefore we run a separate migration process just before
// starting the server.
postInitMigrator := datastore.NewPostInitMigrator(
postInitMigrator := postinit.NewPostInitMigrator(
kubernetesClientFactory,
dockerClientFactory,
dataStore,
*flags.Assets,
kubernetesDeployer,
)
if err := postInitMigrator.PostInitMigrate(); err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations")
@@ -650,6 +655,7 @@ func main() {
Msg("starting Portainer")
err := server.Start()
log.Info().Err(err).Msg("HTTP server exited")
}
}

View File

@@ -22,6 +22,12 @@ func CreateTLSConfiguration() *tls.Config {
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
},
}
}

View File

@@ -36,6 +36,7 @@ type (
}
DataStore interface {
Connection() portainer.Connection
Open() (newStore bool, err error)
Init() error
Close() error
@@ -73,6 +74,7 @@ type (
PendingActionsService interface {
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
GetNextIdentifier() int
DeleteByEndpointID(ID portainer.EndpointID) error
}
// EdgeStackService represents a service to manage Edge stacks

View File

@@ -1,10 +1,12 @@
package pendingactions
import (
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
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 {
return ServiceTx{
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)
}
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.
func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName)

View File

@@ -19,8 +19,7 @@ type Service struct {
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
if err := connection.SetServiceName(BucketName); err != nil {
return nil, err
}
@@ -32,6 +31,16 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
},
}
}
// TeamByName returns a team by name.
func (service *Service) TeamByName(name string) (*portainer.Team, error) {
var t portainer.Team

View File

@@ -0,0 +1,48 @@
package team
import (
"errors"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]
}
// TeamByName returns a team by name.
func (service ServiceTx) TeamByName(name string) (*portainer.Team, error) {
var t portainer.Team
err := service.Tx.GetAll(
BucketName,
&portainer.Team{},
dataservices.FirstFn(&t, func(e portainer.Team) bool {
return strings.EqualFold(e.Name, name)
}),
)
if errors.Is(err, dataservices.ErrStop) {
return &t, nil
}
if err == nil {
return nil, dserrors.ErrObjectNotFound
}
return nil, err
}
// CreateTeam creates a new Team.
func (service ServiceTx) Create(team *portainer.Team) error {
return service.Tx.CreateObject(
BucketName,
func(id uint64) (int, any) {
team.ID = portainer.TeamID(id)
return int(team.ID), team
},
)
}

View File

@@ -86,6 +86,7 @@ func (store *Store) newMigratorParameters(version *models.Version) *migrator.Mig
EdgeStackService: store.EdgeStackService,
EdgeJobService: store.EdgeJobService,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
}
}

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

View File

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

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/dataservices/endpointrelation"
"github.com/portainer/portainer/api/dataservices/extension"
"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/resourcecontrol"
"github.com/portainer/portainer/api/dataservices/role"
@@ -58,6 +59,7 @@ type (
edgeStackService *edgestack.Service
edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
}
// MigratorParameters represents the required parameters to create a new Migrator instance.
@@ -85,6 +87,7 @@ type (
EdgeStackService *edgestack.Service
EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
}
)
@@ -114,6 +117,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
edgeStackService: parameters.EdgeStackService,
edgeJobService: parameters.EdgeJobService,
TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
}
migrator.initMigrations()
@@ -232,6 +236,9 @@ func (m *Migrator) initMigrations() {
m.updateAppTemplatesVersionForDB110,
m.updateResourceOverCommitToDB110,
)
m.addMigrations("2.20.2",
m.cleanPendingActionsForDeletedEndpointsForDB111,
)
// Add new migrations below...
// One function per migration, each versions migration funcs in the same file.

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

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

View File

@@ -402,7 +402,6 @@ type storeExport struct {
}
func (store *Store) Export(filename string) (err error) {
backup := storeExport{}
if c, err := store.CustomTemplate().ReadAll(); err != nil {
@@ -606,6 +605,7 @@ func (store *Store) Export(filename string) (err error) {
if err != nil {
return err
}
return os.WriteFile(filename, b, 0600)
}

View File

@@ -16,7 +16,9 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
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 {
return tx.store.EdgeGroupService.Tx(tx.tx)
@@ -78,7 +80,10 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
return tx.store.TeamMembershipService.Tx(tx.tx)
}
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
func (tx *StoreTx) Team() dataservices.TeamService {
return tx.store.TeamService.Tx(tx.tx)
}
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
func (tx *StoreTx) User() dataservices.UserService {

View File

@@ -631,6 +631,7 @@
"LogoURL": "",
"OAuthSettings": {
"AccessTokenURI": "",
"AuthStyle": 0,
"AuthorizationURI": "",
"ClientID": "",
"DefaultTeamID": 0,
@@ -677,6 +678,7 @@
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
@@ -939,6 +941,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.21.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.21.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -3,7 +3,6 @@ package client
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
@@ -13,7 +12,7 @@ import (
portainer "github.com/portainer/portainer/api"
"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/segmentio/encoding/json"
)
@@ -50,12 +49,12 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
case portainer.AgentOnDockerEnvironment:
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
case portainer.EdgeAgentOnDockerEnvironment:
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
endpointURL := "http://" + tunnelAddr
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
}
@@ -93,11 +92,17 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return nil, err
}
return client.NewClientWithOpts(
opts := []client.Opt{
client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(),
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) {
@@ -159,7 +164,7 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
resp.Body = io.NopCloser(bytes.NewReader(body))
var rs []struct {
types.ImageSummary
image.Summary
Portainer struct {
Agent struct {
NodeName string

View File

@@ -4,15 +4,17 @@ import (
"context"
"strings"
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/docker/images"
"github.com/Masterminds/semver"
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/pkg/errors"
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/docker/images"
"github.com/rs/zerolog/log"
)
@@ -30,6 +32,44 @@ func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataserv
}
}
// applyVersionConstraint uses the version to apply a transformation function to
// the value when the constraint is satisfied
func applyVersionConstraint[T any](currentVersion, versionConstraint string, value T, transform func(T) T) (T, error) {
newValue := value
constraint, err := semver.NewConstraint(versionConstraint)
if err != nil {
return newValue, errors.New("invalid version constraint specified")
}
currentVer, err := semver.NewVersion(currentVersion)
if err != nil {
log.Warn().Err(err).Msg("Unable to parse the Docker client version")
return newValue, nil
}
if satisfiesConstraint, _ := constraint.Validate(currentVer); satisfiesConstraint {
newValue = transform(value)
}
return newValue, nil
}
func clearMacAddrs(n network.NetworkingConfig) network.NetworkingConfig {
netConfig := network.NetworkingConfig{
EndpointsConfig: make(map[string]*network.EndpointSettings),
}
for k := range n.EndpointsConfig {
endpointConfig := n.EndpointsConfig[k].Copy()
endpointConfig.MacAddress = ""
netConfig.EndpointsConfig[k] = endpointConfig
}
return netConfig
}
// Recreate a container
func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerId string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error) {
cli, err := c.factory.CreateClient(endpoint, nodeName, nil)
@@ -90,7 +130,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
return nil, errors.Wrap(err, "rename container error")
}
networkWithCreation := network.NetworkingConfig{
initialNetwork := network.NetworkingConfig{
EndpointsConfig: make(map[string]*network.EndpointSettings),
}
@@ -103,10 +143,10 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
}
// 5. get the first network attached to the current container
if len(networkWithCreation.EndpointsConfig) == 0 {
if len(initialNetwork.EndpointsConfig) == 0 {
// Retrieve the first network that is linked to the present container, which
// will be utilized when creating the container.
networkWithCreation.EndpointsConfig[name] = network
initialNetwork.EndpointsConfig[name] = network
}
}
c.sr.enable()
@@ -119,7 +159,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
for _, network := range container.NetworkSettings.Networks {
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")
@@ -130,12 +170,20 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// to retain the same network settings we have to connect on creation to one of the old
// container's networks, and connect to the other networks after creation.
// see: https://portainer.atlassian.net/browse/EE-5448
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &networkWithCreation, nil, container.Name)
// Docker API < 1.44 does not support specifying MAC addresses
// https://github.com/moby/moby/blob/6aea26b431ea152a8b085e453da06ea403f89886/client/container_create.go#L44-L46
initialNetwork, err = applyVersionConstraint(cli.ClientVersion(), "< 1.44", initialNetwork, clearMacAddrs)
if err != nil {
return nil, err
}
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{})
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
})
if err != nil {
@@ -150,8 +198,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
log.Debug().Str("container_id", newContainerId).Msg("connecting networks to container")
networks := container.NetworkSettings.Networks
for key, network := range networks {
_, ok := networkWithCreation.EndpointsConfig[key]
if ok {
if _, ok := initialNetwork.EndpointsConfig[key]; ok {
// skip the network that is used during container creation
continue
}
@@ -164,14 +211,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// 8. start 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 {
return nil, errors.Wrap(err, "start container error")
}
// 9. delete 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()

View File

@@ -0,0 +1,52 @@
package docker
import (
"testing"
"github.com/docker/docker/api/types/network"
"github.com/stretchr/testify/require"
)
func TestApplyVersionConstraint(t *testing.T) {
initialNet := network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
"key1": {
MacAddress: "mac1",
EndpointID: "endpointID1",
},
"key2": {
MacAddress: "mac2",
EndpointID: "endpointID2",
},
},
}
f := func(currentVer string, constraint string, success, emptyMac bool) {
t.Helper()
transformedNet, err := applyVersionConstraint(currentVer, constraint, initialNet, clearMacAddrs)
if success {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.Len(t, transformedNet.EndpointsConfig, len(initialNet.EndpointsConfig))
for k := range initialNet.EndpointsConfig {
if emptyMac {
require.NotEqual(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
require.Empty(t, transformedNet.EndpointsConfig[k].MacAddress)
continue
}
require.Equal(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
}
}
f("1.45", "< 1.44", true, false) // No transformation
f("1.43", "< 1.44", true, true) // Transformation
f("a.b.", "< 1.44", true, false) // Invalid current version
f("1.45", "z 1.44", false, false) // Invalid version constraint
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
portainer "github.com/portainer/portainer/api"
consts "github.com/portainer/portainer/api/docker/consts"
@@ -157,7 +158,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
return Error, nil
}
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{
containers, err := cli.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)),
})

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/docker/docker/api/types"
"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/client"
@@ -147,7 +148,7 @@ func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Clien
}
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 {
return err
}

View File

@@ -3,7 +3,6 @@ package exec
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path"
@@ -186,11 +185,11 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnel, err := manager.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
endpointURL = "tcp://" + tunnelAddr
}
args = append(args, "-H", endpointURL)

View File

@@ -934,7 +934,7 @@ func FileExists(filePath string) (bool, error) {
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
// 1. Backup the source directory to a different folder
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
err := MoveDirectory(originalPath, backupDir)
err := MoveDirectory(originalPath, backupDir, false)
if err != nil {
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)
}
err = MoveDirectory(backupDir, src)
err = MoveDirectory(backupDir, src, false)
if err != nil {
return fmt.Errorf("failed to restore backup directory: %w", err)
}
return nil
}
func MoveDirectory(originalPath, newPath string) error {
func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error {
if _, err := os.Stat(originalPath); err != nil {
return err
}
@@ -991,7 +991,13 @@ func MoveDirectory(originalPath, newPath string) error {
}
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)

View File

@@ -16,7 +16,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
file1 := addFile(destinationDir, "dir", "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.FileExists(t, file1, "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")
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.FileExists(t, file1, "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")
}
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) {
tmp := t.TempDir()
sourceDir := path.Join(tmp, "source")
@@ -46,7 +62,7 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
file2 := addFile(sourceDir, "file")
destinationDir := path.Join(tmp, "destination")
err := MoveDirectory(sourceDir, destinationDir)
err := MoveDirectory(sourceDir, destinationDir, false)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")

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 {
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)
if err != nil {
cleanUp = false
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath)
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false)
if restoreError != nil {
log.Warn().Err(restoreError).Msg("failed restoring backup folder")
}

View File

@@ -143,6 +143,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
ReferenceName: plumbing.ReferenceName(opt.referenceName),
Auth: getAuth(opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
Tags: git.NoTags,
}
repo, err := git.Clone(memory.NewStorage(), nil, cloneOption)
@@ -166,7 +167,10 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
}
var allPaths []string
w := object.NewTreeWalker(tree, true, nil)
defer w.Close()
for {
name, entry, err := w.Next()
if err != nil {

View File

@@ -91,6 +91,29 @@ func Test_latestCommitID(t *testing.T) {
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
}
func Test_ListRefs(t *testing.T) {
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
assert.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs)
}
func Test_ListFiles(t *testing.T) {
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
referenceName := "refs/heads/main"
fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false)
assert.NoError(t, err)
assert.Equal(t, []string{"docker-compose.yml"}, fs)
}
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
repo, err := git.PlainOpen(dir)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
lru "github.com/hashicorp/golang-lru"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
const (
@@ -223,11 +224,23 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
return refs, nil
}
var singleflightGroup = &singleflight.Group{}
// ListFiles will list all the files of the target repository with specific extensions.
// If extension is not provided, it will list all the files under the target repository
func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) {
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify)
})
return filterFiles(fs.([]string), includedExts), err
}
func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) {
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result
service.repoFileCache.Remove(repoKey)
@@ -235,14 +248,9 @@ func (service *Service) ListFiles(repositoryURL, referenceName, username, passwo
if service.repoFileCache != nil {
// lookup the files cache first
cache, ok := service.repoFileCache.Get(repoKey)
if ok {
files, success := cache.([]string)
if success {
// For the case while searching files in a repository without include extensions for the first time,
// but with include extensions for the second time
includedFiles := filterFiles(files, includedExts)
return includedFiles, nil
if cache, ok := service.repoFileCache.Get(repoKey); ok {
if files, ok := cache.([]string); ok {
return files, nil
}
}
}
@@ -274,12 +282,11 @@ func (service *Service) ListFiles(repositoryURL, referenceName, username, passwo
}
}
includedFiles := filterFiles(files, includedExts)
if service.cacheEnabled && service.repoFileCache != nil {
service.repoFileCache.Add(repoKey, includedFiles)
return includedFiles, nil
service.repoFileCache.Add(repoKey, files)
}
return includedFiles, nil
return files, nil
}
func (service *Service) purgeCache() {

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"fmt"
"net/http"
"os"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -13,6 +14,13 @@ import (
)
func WithProtect(handler http.Handler) (http.Handler, error) {
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
isDockerDesktopExtension := false
if val, ok := os.LookupEnv("DOCKER_EXTENSION"); ok && val == "1" {
isDockerDesktopExtension = true
}
handler = withSendCSRFToken(handler)
token := make([]byte, 32)
@@ -27,7 +35,7 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
gorillacsrf.Secure(false),
)(handler)
return withSkipCSRF(handler), nil
return withSkipCSRF(handler, isDockerDesktopExtension), nil
}
func withSendCSRFToken(handler http.Handler) http.Handler {
@@ -48,10 +56,10 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
})
}
func withSkipCSRF(handler http.Handler) http.Handler {
func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r)
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return

View File

@@ -75,7 +75,12 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
if settings.AuthenticationMethod == portainer.AuthenticationInternal ||
settings.AuthenticationMethod == portainer.AuthenticationOAuth ||
(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 {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
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 {

View File

@@ -28,5 +28,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
security.RemoveAuthCookie(w)
handler.bouncer.RevokeJWT(tokenData.Token)
return response.Empty(w)
}

View File

@@ -70,8 +70,7 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
if err != nil {
log.Warn().Err(err).Msg("failed to download git repository")
if err != nil {
rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath)
if rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath); rbErr != nil {
return httperror.InternalServerError("Failed to rollback the custom template folder", rbErr)
}

View File

@@ -2,6 +2,7 @@ package customtemplates
import (
"bytes"
"errors"
"io"
"io/fs"
"net/http"
@@ -19,6 +20,7 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -49,6 +51,19 @@ func (f *TestFileService) GetFileContent(projectPath, configFilePath string) ([]
return os.ReadFile(filepath.Join(projectPath, configFilePath))
}
type InvalidTestGitService struct {
portainer.GitService
targetFilePath string
}
func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error {
return errors.New("simulate network error")
}
func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
return "", nil
}
func createTestFile(targetPath string) error {
f, err := os.Create(targetPath)
if err != nil {
@@ -174,4 +189,28 @@ func Test_customTemplateGitFetch(t *testing.T) {
singleAPIRequest(h, jwt2, is, "gfedcba")
})
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
invalidGitService := &InvalidTestGitService{
targetFilePath: filepath.Join(template1.ProjectPath, template1.GitConfig.ConfigFilePath),
}
h := NewHandler(requestBouncer, store, fileService, invalidGitService)
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}")))
testhelpers.AddTestSecurityCookie(req, jwt1)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusInternalServerError, rr.Code)
var errResp httperror.HandlerError
err = json.NewDecoder(rr.Body).Decode(&errResp)
assert.NoError(t, err, "failed to parse error body")
assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored")
fileContent, err := os.ReadFile(gitService.targetFilePath)
assert.NoError(t, err, "failed to read target file")
assert.Equal(t, "gfedcba", string(fileContent))
})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -30,7 +31,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess)
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)

View File

@@ -40,14 +40,14 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
}
// endpoints
endpointRouter := h.PathPrefix("/{id}").Subrouter()
endpointRouter := h.PathPrefix("/docker/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
endpointRouter.Use(dockerOnlyMiddleware)
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
imagesHandler := images.NewHandler("/{id}/images", bouncer, dockerClientFactory)
imagesHandler := images.NewHandler("/docker/{id}/images", bouncer, dockerClientFactory)
endpointRouter.PathPrefix("/images").Handler(imagesHandler)
return h
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -25,7 +26,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dockerClien
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess)
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("", httperror.LoggerHandler(h.imagesList)).Methods(http.MethodGet)
return h

View File

@@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
)
type ImageResponse struct {
@@ -63,7 +64,9 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imageUsageSet := set.Set[string]{}
if withUsage {
containers, err := cli.ContainerList(r.Context(), types.ContainerListOptions{})
containers, err := cli.ContainerList(r.Context(), container.ListOptions{
All: true,
})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
}
@@ -75,7 +78,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imagesList := make([]ImageResponse, len(images))
for i, image := range images {
if (image.RepoTags == nil || len(image.RepoTags) == 0) && (image.RepoDigests != nil && len(image.RepoDigests) > 0) {
if len(image.RepoTags) == 0 && len(image.RepoDigests) > 0 {
for _, repoDigest := range image.RepoDigests {
image.RepoTags = append(image.RepoTags, repoDigest[0:strings.Index(repoDigest, "@")]+":<none>")
}

View File

@@ -19,8 +19,9 @@ import (
// @security jwt
// @param id path int true "EdgeGroup Id"
// @success 204
// @failure 409 "Edge group is in use by an Edge stack or Edge job"
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @failure 500 "Server error"
// @router /edge_groups/{id} [delete]
func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")

View File

@@ -15,10 +15,13 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
type stackStatusResponse struct {
@@ -92,6 +95,8 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
firstConn := endpoint.LastCheckInDate == 0
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
@@ -106,7 +111,7 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
var statusResponse *endpointEdgeStatusInspectResponse
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID))
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID), firstConn)
return err
})
if err != nil {
@@ -121,7 +126,7 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
return cacheResponse(w, endpoint.ID, *statusResponse)
}
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID) (*endpointEdgeStatusInspectResponse, error) {
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID, firstConn bool) (*endpointEdgeStatusInspectResponse, error) {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return nil, err
@@ -133,8 +138,10 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
}
// Take an initial snapshot
if endpoint.LastCheckInDate == 0 {
handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if firstConn {
if err := handler.ReverseTunnelService.Open(endpoint); err != nil {
log.Error().Err(err).Msg("could not open the tunnel")
}
}
agentPlatform, agentPlatformErr := parseAgentPlatform(r)
@@ -153,34 +160,21 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
return nil, httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
checkinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
settings, err := tx.Settings().Settings()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
checkinInterval = settings.EdgeAgentCheckinInterval
}
tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID)
tunnel := handler.ReverseTunnelService.Config(endpoint.ID)
statusResponse := endpointEdgeStatusInspectResponse{
Status: tunnel.Status,
Port: tunnel.Port,
CheckinInterval: checkinInterval,
CheckinInterval: edge.EffectiveCheckinInterval(tx, endpoint),
Credentials: tunnel.Credentials,
}
schedules, handlerErr := handler.buildSchedules(endpoint.ID, tunnel)
schedules, handlerErr := handler.buildSchedules(endpoint.ID)
if handlerErr != nil {
return nil, handlerErr
}
statusResponse.Schedules = schedules
if tunnel.Status == portainer.EdgeAgentManagementRequired {
handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID)
}
edgeStacksStatus, handlerErr := handler.buildEdgeStacks(tx, endpoint.ID)
if handlerErr != nil {
return nil, handlerErr
@@ -213,9 +207,9 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
}
}
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID, tunnel portainer.TunnelDetails) ([]edgeJobResponse, *httperror.HandlerError) {
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
for _, job := range tunnel.Jobs {
for _, job := range handler.ReverseTunnelService.EdgeJobs(endpointID) {
var collectLogs bool
if _, ok := job.GroupLogsCollection[endpointID]; ok {
collectLogs = job.GroupLogsCollection[endpointID].CollectLogs

View File

@@ -154,7 +154,7 @@ func TestMissingEdgeIdentifier(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code))
t.Fatalf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code)
}
}
@@ -179,7 +179,7 @@ func TestWithEndpoints(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != test.expectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID))
t.Fatalf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID)
}
}
}
@@ -219,7 +219,7 @@ func TestLastCheckInDateIncreases(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
@@ -262,7 +262,7 @@ func TestEmptyEdgeIdWithAgentPlatformHeader(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code))
t.Fatalf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code)
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
@@ -326,7 +326,7 @@ func TestEdgeStackStatus(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
}
var data endpointEdgeStatusInspectResponse
@@ -391,7 +391,7 @@ func TestEdgeJobsResponse(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
}
var data endpointEdgeStatusInspectResponse

View File

@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/tag"
pendingActionActions "github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -95,8 +96,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
payloadTagSet := tag.Set(payload.TagIDs)
endpointGroupTagSet := tag.Set((endpointGroup.TagIDs))
union := tag.Union(payloadTagSet, endpointGroupTagSet)
intersection := tag.Intersection(payloadTagSet, endpointGroupTagSet)
tagsChanged = len(union) > len(intersection)
intersection := tag.IntersectionCount(payloadTagSet, endpointGroupTagSet)
tagsChanged = len(union) > intersection
if tagsChanged {
removeTags := tag.Difference(endpointGroupTagSet, payloadTagSet)
@@ -159,7 +160,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
err := handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointID,
Action: "CleanNAPWithOverridePolicies",
ActionData: endpointGroupID,
ActionData: &pendingActionActions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: endpointGroupID,
},
})
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)

View File

@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}

View File

@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}

View File

@@ -59,8 +59,6 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
return httperror.InternalServerError("Failed persisting environment in database", err)
}
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
return response.Empty(w)
}

View File

@@ -201,6 +201,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @param Gpus formData string false "List of GPUs - json stringified array of {name, value} structs"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints [post]
func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -2,6 +2,7 @@ package endpoints
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
@@ -17,19 +18,40 @@ import (
"github.com/rs/zerolog/log"
)
type endpointDeleteRequest struct {
ID int `json:"id"`
DeleteCluster bool `json:"deleteCluster"`
}
type endpointDeleteBatchPayload struct {
Endpoints []endpointDeleteRequest `json:"endpoints"`
}
type endpointDeleteBatchPartialResponse struct {
Deleted []int `json:"deleted"`
Errors []int `json:"errors"`
}
func (payload *endpointDeleteBatchPayload) Validate(r *http.Request) error {
if payload == nil || len(payload.Endpoints) == 0 {
return fmt.Errorf("invalid request payload. You must provide a list of environments to delete")
}
return nil
}
// @id EndpointDelete
// @summary Remove an environment(endpoint)
// @description Remove an environment(endpoint).
// @description **Access policy**: administrator
// @summary Remove an environment
// @description Remove the environment associated to the specified identifier and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth
// @security jwt
// @security ApiKeyAuth || jwt
// @param id path int true "Environment(Endpoint) identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Environment(Endpoint) not found"
// @failure 500 "Server error"
// @success 204 "Environment successfully deleted."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "Unable to find the environment with the specified identifier inside the database."
// @failure 500 "Server error occurred while attempting to delete the environment."
// @router /endpoints/{id} [delete]
func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
@@ -62,6 +84,63 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
return response.Empty(w)
}
// @id EndpointDeleteBatch
// @summary Remove multiple environments
// @description Remove multiple environments and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up assocaited resources (cloud environments only)"
// @success 204 "Environment(s) successfully deleted."
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
// @router /endpoints [delete]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
resp := endpointDeleteBatchPartialResponse{
Deleted: []int{},
Errors: []int{},
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
for _, e := range p.Endpoints {
if handler.demoService.IsDemoEnvironment(portainer.EndpointID(e.ID)) {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(httperrors.ErrNotAvailableInDemo).Msgf("Unable to remove demo environment %d", e.ID)
continue
}
if err := handler.deleteEndpoint(tx, portainer.EndpointID(e.ID), e.DeleteCluster); err != nil {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(err).Int("environment_id", e.ID).Msg("Unable to remove environment")
continue
}
resp.Deleted = append(resp.Deleted, e.ID)
}
return nil
}); err != nil {
return httperror.InternalServerError("Unable to delete environments", err)
}
if len(resp.Errors) > 0 {
return response.JSONWithStatus(w, resp, http.StatusPartialContent)
}
return response.Empty(w)
}
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if tx.IsErrObjectNotFound(err) {
@@ -78,23 +157,20 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
}
err = tx.Snapshot().Delete(endpointID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove the snapshot from the database")
if err := tx.Snapshot().Delete(endpointID); err != nil {
log.Warn().Err(err).Msg("Unable to remove the snapshot from the database")
}
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
err = handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update user authorizations")
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
}
}
err = tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove environment relation from the database")
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
}
for _, tagID := range endpoint.TagIDs {
@@ -106,9 +182,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if handler.DataStore.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msgf("Unable to find tag inside the database")
log.Warn().Err(err).Msg("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msgf("Unable to delete tag relation from the database")
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
}
}
@@ -122,40 +198,39 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
return e == endpoint.ID
})
err = tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge group")
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
log.Warn().Err(err).Msg("Unable to update edge group")
}
}
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve edge stacks from the database")
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
}
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
delete(edgeStack.Status, endpoint.ID)
err = tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge stack")
if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil {
log.Warn().Err(err).Msg("Unable to update edge stack")
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve registries from the database")
log.Warn().Err(err).Msg("Unable to retrieve registries from the database")
}
for idx := range registries {
registry := &registries[idx]
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
delete(registry.RegistryAccesses, endpoint.ID)
err = tx.Registry().Update(registry.ID, registry)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update registry accesses")
if err := tx.Registry().Update(registry.ID, registry); err != nil {
log.Warn().Err(err).Msg("Unable to update registry accesses")
}
}
}
@@ -163,7 +238,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve edge jobs from the database")
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
}
for idx := range edgeJobs {
@@ -171,14 +246,18 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if _, ok := edgeJob.Endpoints[endpoint.ID]; ok {
delete(edgeJob.Endpoints, endpoint.ID)
err = tx.EdgeJob().Update(edgeJob.ID, edgeJob)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge job")
if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil {
log.Warn().Err(err).Msg("Unable to update edge job")
}
}
}
}
// delete the pending actions
if err := tx.PendingActions().DeleteByEndpointID(endpoint.ID); err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msg("Unable to delete pending actions")
}
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
if err != nil {
return httperror.InternalServerError("Unable to delete the environment from the database", err)

View File

@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
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

View File

@@ -12,8 +12,8 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"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"
)
@@ -39,7 +39,7 @@ func (payload *forceUpdateServicePayload) Validate(r *http.Request) error {
// @produce json
// @param id path int true "endpoint identifier"
// @param body body forceUpdateServicePayload true "details"
// @success 200 {object} dockertypes.ServiceUpdateResponse "Success"
// @success 200 {object} swarm.ServiceUpdateResponse "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "endpoint not found"
@@ -94,7 +94,7 @@ func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *htt
go func() {
images.EvictImageStatus(payload.ServiceID)
images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel])
containers, _ := dockerClient.ContainerList(context.TODO(), types.ContainerListOptions{
containers, _ := dockerClient.ContainerList(context.TODO(), container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)),
})

View File

@@ -3,15 +3,16 @@ package endpoints
import (
"net/http"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
// @id endpointRegistriesList
@@ -127,7 +128,7 @@ func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, name
return true, nil
}
if namespace == "default" {
if !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace && namespace == kubernetes.DefaultNamespace {
return true, nil
}

View File

@@ -69,6 +69,7 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints/{id} [put]
func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -193,7 +193,7 @@ func (handler *Handler) filterEndpointsByQuery(
return nil, 0, errors.WithMessage(err, "Unable to retrieve tags from the database")
}
tagsMap := make(map[portainer.TagID]string)
tagsMap := make(map[portainer.TagID]string, len(tags))
for _, tag := range tags {
tagsMap[tag.ID] = tag.Name
}
@@ -302,8 +302,7 @@ func filterEndpointsBySearchCriteria(
) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
if endpointMatchSearchCriteria(&endpoint, tagsMap, searchCriteria) {
endpoints[n] = endpoint
n++
@@ -317,7 +316,7 @@ func filterEndpointsBySearchCriteria(
continue
}
if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpoints, endpointGroups) {
if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpointGroups) {
endpoints[n] = endpoint
n++
@@ -334,11 +333,16 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portai
status := endpoint.Status
if endpointutils.IsEdgeEndpoint(&endpoint) {
isCheckValid := false
edgeCheckinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
if edgeCheckinInterval == 0 {
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
if endpoint.Edge.AsyncMode {
edgeCheckinInterval = getShortestAsyncInterval(&endpoint, settings)
}
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
}
@@ -358,7 +362,7 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portai
return endpoints[:n]
}
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
@@ -373,8 +377,8 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, se
return true
}
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
for _, tagID := range endpoint.TagIDs {
if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) {
return true
}
}
@@ -384,16 +388,17 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, se
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
if group.ID != endpoint.GroupID {
continue
}
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
for _, tagID := range group.TagIDs {
if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) {
return true
}
}
}
@@ -406,11 +411,10 @@ func edgeGroupMatchSearchCriteria(
endpoint *portainer.Endpoint,
edgeGroups []portainer.EdgeGroup,
searchCriteria string,
endpoints []portainer.Endpoint,
endpointGroups []portainer.EndpointGroup,
) bool {
for _, edgeGroup := range edgeGroups {
relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, endpoints, endpointGroups)
relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, []portainer.Endpoint{*endpoint}, endpointGroups)
for _, endpointID := range relatedEndpointIDs {
if endpointID == endpoint.ID {
@@ -441,16 +445,6 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []port
return endpoints[:n]
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0, len(tagIDs))
for _, tagID := range tagIDs {
tags = append(tags, tagsMap[tagID])
}
return tags
}
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
@@ -622,9 +616,36 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
portainer.EdgeStackStatusRunning,
portainer.EdgeStackStatusDeploying,
portainer.EdgeStackStatusRemoving,
portainer.EdgeStackStatusCompleted,
}, edgeStackStatus) {
return nil, errors.New("invalid edgeStackStatus parameter")
}
return &edgeStackStatus, nil
}
func getShortestAsyncInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
var edgeIntervalUseDefault int = -1
pingInterval := endpoint.Edge.PingInterval
if pingInterval == edgeIntervalUseDefault {
pingInterval = settings.Edge.PingInterval
}
shortestAsyncInterval := pingInterval
snapshotInterval := endpoint.Edge.SnapshotInterval
if snapshotInterval == edgeIntervalUseDefault {
snapshotInterval = settings.Edge.SnapshotInterval
}
if shortestAsyncInterval > snapshotInterval {
shortestAsyncInterval = snapshotInterval
}
commandInterval := endpoint.Edge.CommandInterval
if commandInterval == edgeIntervalUseDefault {
commandInterval = settings.Edge.CommandInterval
}
if shortestAsyncInterval > commandInterval {
shortestAsyncInterval = commandInterval
}
return shortestAsyncInterval
}

View File

@@ -1,6 +1,7 @@
package endpoints
import (
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -148,6 +149,103 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments)
}
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000
endpointIDs := []portainer.EndpointID{}
endpoints := []portainer.Endpoint{}
for i := 0; i < n; i++ {
endpoints = append(endpoints, portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "endpoint-" + strconv.Itoa(i+1),
GroupID: 1,
TagIDs: []portainer.TagID{1},
Type: portainer.EdgeAgentOnDockerEnvironment,
})
endpointIDs = append(endpointIDs, portainer.EndpointID(i+1))
}
endpointGroups := []portainer.EndpointGroup{}
edgeGroups := []portainer.EdgeGroup{}
for i := 0; i < 1000; i++ {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
Dynamic: true,
TagIDs: []portainer.TagID{1, 2, 3},
PartialMatch: true,
})
}
tagsMap := map[portainer.TagID]string{}
for i := 0; i < 10; i++ {
tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1)
}
searchString := "edge-group"
b.ResetTimer()
for i := 0; i < b.N; i++ {
e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString)
if len(e) != n {
b.FailNow()
}
}
}
func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) {
n := 10000
endpointIDs := []portainer.EndpointID{}
endpoints := []portainer.Endpoint{}
for i := 0; i < n; i++ {
endpoints = append(endpoints, portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "endpoint-" + strconv.Itoa(i+1),
GroupID: 1,
TagIDs: []portainer.TagID{1, 2, 3},
Type: portainer.EdgeAgentOnDockerEnvironment,
})
endpointIDs = append(endpointIDs, portainer.EndpointID(i+1))
}
endpointGroups := []portainer.EndpointGroup{}
edgeGroups := []portainer.EdgeGroup{}
for i := 0; i < 1000; i++ {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
Dynamic: true,
TagIDs: []portainer.TagID{1},
})
}
tagsMap := map[portainer.TagID]string{}
for i := 0; i < 10; i++ {
tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1)
}
searchString := "edge-group"
b.ResetTimer()
for i := 0; i < b.N; i++ {
e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString)
if len(e) != n {
b.FailNow()
}
}
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {

View File

@@ -71,6 +71,8 @@ func NewHandler(bouncer security.BouncerService, demoService *demo.Service) *Han
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",

View File

@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.21.0
// @version 2.21.3
// @description.markdown api-description.md
// @termsOfService
@@ -199,7 +199,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/docker"):
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
http.StripPrefix("/api", h.DockerHandler).ServeHTTP(w, r)
// Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm
case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"):

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)
}
w.WriteHeader(http.StatusCreated)
return response.JSON(w, release)
return response.JSONWithStatus(w, release, http.StatusCreated)
}
func (p *installChartPayload) Validate(_ *http.Request) error {

View File

@@ -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
// TODO: add k8s support
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.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
@@ -170,7 +170,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
}
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 {
log.Error().
Str("image_name", imageName).
@@ -211,7 +211,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
return "", err
}
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
if err != nil {
log.Error().
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")
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 {
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).

View File

@@ -7,12 +7,16 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
func hideFields(registry *portainer.Registry, hideAccesses bool) {
@@ -83,29 +87,88 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
}
func (handler *Handler) userHasRegistryAccess(r *http.Request) (hasAccess bool, isAdmin bool, err error) {
// this function validates that
//
// 1. user has the appropriate authorizations to perform the request
//
// 2. user has a direct or indirect access to the registry
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return false, false, err
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return false, false, err
}
// Portainer admins always have access to everything
if securityContext.IsAdmin {
return true, true, nil
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return false, false, err
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
// mandatory query param that should become a path param
endpointIdStr, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return false, false, err
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
endpointId := portainer.EndpointID(endpointIdStr)
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
if err != nil {
return false, false, err
}
return true, false, nil
// validate that the request is allowed for the user (READ/WRITE authorization on request path)
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); errors.Is(err, security.ErrAuthorizationRequired) {
return false, false, nil
} else if err != nil {
return false, false, err
}
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return false, false, nil
}
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) {
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
}
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, false, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, false, nil
}
}
return false, false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
return true, false, nil
}
// when user has no access via their role, direct grant or indirect grant
// then they don't have access to the registry
return false, false, nil
}

View File

@@ -89,6 +89,7 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error {
// @param body body registryCreatePayload true "Registry details"
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 500 "Server error"
// @router /registries [post]
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -8,6 +8,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -91,7 +92,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) er
if len(failedNamespaces) > 0 {
handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointId,
Action: pendingactions.DeletePortainerK8sRegistrySecrets,
Action: actions.DeletePortainerK8sRegistrySecrets,
// When extracting the data, this is the type we need to pull out
// i.e. pendingactions.DeletePortainerK8sRegistrySecretsData

View File

@@ -26,14 +26,6 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [get]
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid registry identifier route variable", err)
@@ -46,6 +38,14 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
}
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
hideFields(registry, !isAdmin)
return response.JSON(w, registry)
}

View File

@@ -52,7 +52,7 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 404 "Registry not found"
// @failure 409 "Another registry with the same URL already exists"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 500 "Server error"
// @router /registries/{id} [put]
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -63,7 +63,7 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error {
// @param body body resourceControlCreatePayload true "Resource control details"
// @success 200 {object} portainer.ResourceControl "Success"
// @failure 400 "Invalid request"
// @failure 409 "Resource control already exists"
// @failure 409 "A resource control is already associated to this resource"
// @failure 500 "Server error"
// @router /resource_controls [post]
func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -13,6 +13,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"golang.org/x/oauth2"
"github.com/asaskevich/govalidator"
"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
}
@@ -225,6 +231,7 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.OAuthSettings = *payload.OAuthSettings
settings.OAuthSettings.ClientSecret = clientSecret
settings.OAuthSettings.KubeSecretKey = kubeSecret
settings.OAuthSettings.AuthStyle = payload.OAuthSettings.AuthStyle
}
if payload.EnableEdgeComputeFeatures != nil {

View File

@@ -229,6 +229,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
// @param body body composeStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/standalone/repository [post]
func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -195,6 +195,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/kubernetes/repository [post]
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -188,6 +188,7 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @param body body swarmStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -21,6 +21,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/gorilla/mux"
"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 {
return false, err
}

View File

@@ -1,6 +1,7 @@
package stacks
import (
"errors"
"fmt"
"net/http"
"time"
@@ -95,7 +96,7 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
}
if !canManage {
errMsg := "Stack management is disabled for non-admin users"
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
return httperror.Forbidden(errMsg, errors.New(errMsg))
}
stack.EndpointID = portainer.EndpointID(endpointID)

View File

@@ -111,7 +111,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
}
if !canManage {
errMsg := "stack deletion is disabled for non-admin users"
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
return httperror.Forbidden(errMsg, errors.New(errMsg))
}
// stop scheduler updates of the stack before removal
@@ -338,7 +338,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
}
if !canManage {
errMsg := "stack deletion is disabled for non-admin users"
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
return httperror.Forbidden(errMsg, errors.New(errMsg))
}
stacksToDelete = append(stacksToDelete, stack)

View File

@@ -46,6 +46,7 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error {
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Stack not found"
// @failure 409 "A stack with the same name is already running on the target environment(endpoint)"
// @failure 500 "Server error"
// @router /stacks/{id}/migrate [post]
func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -29,6 +29,7 @@ import (
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
// @failure 409 "Stack name is not unique"
// @failure 500 "Server error"
// @router /stacks/{id}/start [post]
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -19,7 +19,7 @@ import (
// @param webhookID path string true "Stack identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 409 "Conflict"
// @failure 409 "Autoupdate for the stack isn't available"
// @failure 500 "Server error"
// @router /stacks/webhooks/{webhookID} [post]
func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -35,7 +35,7 @@ func (payload *tagCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body tagCreatePayload true "Tag details"
// @success 200 {object} portainer.Tag "Success"
// @failure 409 "Tag name exists"
// @failure 409 "This name is already associated to a tag"
// @failure 500 "Server error"
// @router /tags [post]
func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -5,6 +5,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -23,6 +24,7 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid team name")
}
return nil
}
@@ -38,31 +40,47 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
// @param body body teamCreatePayload true "details"
// @success 200 {object} portainer.Team "Success"
// @failure 400 "Invalid request"
// @failure 409 "Team already exists"
// @failure 409 "A team with the same name already exists"
// @failure 500 "Server error"
// @router /teams [post]
func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload teamCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
team, err := handler.DataStore.Team().TeamByName(payload.Name)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
var team *portainer.Team
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
team, err = createTeam(tx, payload)
return err
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, team)
}
func createTeam(tx dataservices.DataStoreTx, payload teamCreatePayload) (*portainer.Team, error) {
team, err := tx.Team().TeamByName(payload.Name)
if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, httperror.InternalServerError("Unable to retrieve teams from the database", err)
}
if team != nil {
return httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
return nil, httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
}
team = &portainer.Team{
Name: payload.Name,
}
team = &portainer.Team{Name: payload.Name}
err = handler.DataStore.Team().Create(team)
if err != nil {
return httperror.InternalServerError("Unable to persist the team inside the database", err)
if err := tx.Team().Create(team); err != nil {
return nil, httperror.InternalServerError("Unable to persist the team inside the database", err)
}
for _, teamLeader := range payload.TeamLeaders {
@@ -72,11 +90,10 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http
Role: portainer.TeamLeader,
}
err = handler.DataStore.TeamMembership().Create(membership)
if err != nil {
return httperror.InternalServerError("Unable to persist team leadership inside the database", err)
if err := tx.TeamMembership().Create(membership); err != nil {
return nil, httperror.InternalServerError("Unable to persist team leadership inside the database", err)
}
}
return response.JSON(w, team)
return team, nil
}

View File

@@ -0,0 +1,65 @@
package teams
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
func TestConcurrentTeamCreation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
h := &Handler{
DataStore: store,
}
tcp := teamCreatePayload{
Name: "portainer",
}
m, err := json.Marshal(tcp)
require.NoError(t, err)
errGroup := &errgroup.Group{}
n := 100
for i := 0; i < n; i++ {
errGroup.Go(func() error {
req, err := http.NewRequest(http.MethodPost, "/teams", bytes.NewReader(m))
if err != nil {
return err
}
if err := h.teamCreate(httptest.NewRecorder(), req); err != nil {
return err
}
return nil
})
}
err = errGroup.Wait()
require.Error(t, err)
teams, err := store.Team().ReadAll()
require.NotEmpty(t, teams)
require.NoError(t, err)
teamCreated := false
for _, team := range teams {
if team.Name == tcp.Name {
require.False(t, teamCreated)
teamCreated = true
}
}
require.True(t, teamCreated)
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -54,12 +55,33 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
return httperror.BadRequest("Invalid request payload", err)
}
user, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
var user *portainer.User
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
user, err = handler.createUser(tx, payload)
return err
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, user)
}
func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCreatePayload) (*portainer.User, error) {
user, err := tx.User().UserByUsername(payload.Username)
if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, httperror.InternalServerError("Unable to retrieve users from the database", err)
}
if user != nil {
return httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
return nil, httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
}
user = &portainer.User{
@@ -67,33 +89,33 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
Role: portainer.UserRole(payload.Role),
}
settings, err := handler.DataStore.Settings().Settings()
settings, err := tx.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
// when ldap/oauth is on, can only add users without password
// When LDAP/OAuth is on, can only add users without password
if (settings.AuthenticationMethod == portainer.AuthenticationLDAP || settings.AuthenticationMethod == portainer.AuthenticationOAuth) && payload.Password != "" {
errMsg := "A user with password can not be created when authentication method is Oauth or LDAP"
return httperror.BadRequest(errMsg, errors.New(errMsg))
errMsg := "a user with password can not be created when authentication method is Oauth or LDAP"
return nil, httperror.BadRequest(errMsg, errors.New(errMsg))
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
if !handler.passwordStrengthChecker.Check(payload.Password) {
return httperror.BadRequest("Password does not meet the requirements", nil)
return nil, httperror.BadRequest("Password does not meet the requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.Password)
if err != nil {
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
return nil, httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
}
}
err = handler.DataStore.User().Create(user)
if err != nil {
return httperror.InternalServerError("Unable to persist user inside the database", err)
if err := tx.User().Create(user); err != nil {
return nil, httperror.InternalServerError("Unable to persist user inside the database", err)
}
hideFields(user)
return response.JSON(w, user)
return user, nil
}

View File

@@ -50,7 +50,7 @@ type accessTokenResponse struct {
// @produce json
// @param id path int true "User identifier"
// @param body body userAccessTokenCreatePayload true "details"
// @success 201 {object} accessTokenResponse "Created"
// @success 200 {object} accessTokenResponse "Created"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
@@ -115,13 +115,12 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Internal Server Error", err)
}
w.WriteHeader(http.StatusCreated)
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey})
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
}
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
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
}

View File

@@ -0,0 +1,77 @@
package users
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
type mockPasswordStrengthChecker struct{}
func (m *mockPasswordStrengthChecker) Check(string) bool {
return true
}
func TestConcurrentUserCreation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
h := &Handler{
passwordStrengthChecker: &mockPasswordStrengthChecker{},
CryptoService: &crypto.Service{},
DataStore: store,
}
ucp := userCreatePayload{
Username: "portainer",
Password: "password",
Role: int(portainer.AdministratorRole),
}
m, err := json.Marshal(ucp)
require.NoError(t, err)
errGroup := &errgroup.Group{}
n := 100
for i := 0; i < n; i++ {
errGroup.Go(func() error {
req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(m))
if err != nil {
return err
}
if err := h.userCreate(httptest.NewRecorder(), req); err != nil {
return err
}
return nil
})
}
err = errGroup.Wait()
require.Error(t, err)
users, err := store.User().ReadAll()
require.NotEmpty(t, users)
require.NoError(t, err)
userCreated := false
for _, u := range users {
if u.Username == ucp.Username {
require.False(t, userCreated)
userCreated = true
}
}
require.True(t, userCreated)
}

View File

@@ -45,9 +45,9 @@ func (payload *webhookCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body webhookCreatePayload true "Webhook data"
// @success 200 {object} portainer.Webhook
// @failure 400
// @failure 409
// @failure 500
// @failure 400 "Invalid request"
// @failure 409 "A webhook for this resource already exists"
// @failure 500 "Server error"
// @router /webhooks [post]
func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload webhookCreatePayload

View File

@@ -122,6 +122,7 @@ func (handler *Handler) executeServiceWebhook(
_ = rc.Close()
}(rc)
}
_, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions)
if err != nil {

View File

@@ -18,12 +18,12 @@ import (
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnel, err := handler.ReverseTunnelService.GetActiveTunnel(params.endpoint)
tunnelAddr, err := handler.ReverseTunnelService.TunnelAddr(params.endpoint)
if err != nil {
return err
}
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
agentURL, err := url.Parse("http://" + tunnelAddr)
if err != nil {
return err
}
@@ -93,7 +93,7 @@ func (handler *Handler) doProxyWebsocketRequest(
}
if isEdge {
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.UpdateLastActivity(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}

View File

@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
requesthelpers "github.com/portainer/portainer/pkg/libhttp/request"
@@ -63,3 +64,22 @@ func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) {
return contextData.(*portainer.Endpoint), nil
}
func CheckEndpointAuthorization(bouncer security.BouncerService) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpoint, err := FetchEndpoint(r)
if err != nil {
httperror.WriteError(w, http.StatusNotFound, "Unable to find an environment on request context", err)
return
}
if err = bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -26,12 +26,12 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
urlString := endpoint.URL
if endpointutils.IsEdgeEndpoint(endpoint) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed starting tunnel")
}
urlString = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
urlString = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(urlString)

View File

@@ -1,7 +1,6 @@
package factory
import (
"fmt"
"io"
"net/http"
"strings"
@@ -35,8 +34,11 @@ func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
rawURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
rawURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(rawURL)
@@ -65,7 +67,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
DockerClientFactory: factory.dockerClientFactory,
}
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService)
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}

View File

@@ -20,11 +20,16 @@ type postDockerfileRequest struct {
}
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
//
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
// will extract the file content from the request body, tar it, and rewrite the body.
// !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty)
// If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated
//
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
// rewrite the body of the request.
//
// In any other case, it will leave the request unaltered.
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")

View File

@@ -36,6 +36,7 @@ type (
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
}
// TransportParameters is used to create a new Transport
@@ -63,7 +64,7 @@ type (
)
// 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{
endpoint: parameters.Endpoint,
dataStore: parameters.DataStore,
@@ -72,6 +73,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport,
gitService: gitService,
snapshotService: snapshotService,
}
return transport, nil
@@ -82,11 +84,28 @@ func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, er
return transport.ProxyDockerRequest(request)
}
var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*http.Response, error){
"configs": (*Transport).proxyConfigRequest,
"containers": (*Transport).proxyContainerRequest,
"services": (*Transport).proxyServiceRequest,
"volumes": (*Transport).proxyVolumeRequest,
"networks": (*Transport).proxyNetworkRequest,
"secrets": (*Transport).proxySecretRequest,
"swarm": (*Transport).proxySwarmRequest,
"nodes": (*Transport).proxyNodeRequest,
"tasks": (*Transport).proxyTaskRequest,
"build": (*Transport).proxyBuildRequest,
"images": (*Transport).proxyImageRequest,
"v2": (*Transport).proxyAgentRequest,
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = requestPath
// from : /v1.41/containers/{id}/json
// or : /containers/{id}/json
// to : /containers/{id}/json
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -98,34 +117,16 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
switch {
case strings.HasPrefix(requestPath, "/configs"):
return transport.proxyConfigRequest(request)
case strings.HasPrefix(requestPath, "/containers"):
return transport.proxyContainerRequest(request)
case strings.HasPrefix(requestPath, "/services"):
return transport.proxyServiceRequest(request)
case strings.HasPrefix(requestPath, "/volumes"):
return transport.proxyVolumeRequest(request)
case strings.HasPrefix(requestPath, "/networks"):
return transport.proxyNetworkRequest(request)
case strings.HasPrefix(requestPath, "/secrets"):
return transport.proxySecretRequest(request)
case strings.HasPrefix(requestPath, "/swarm"):
return transport.proxySwarmRequest(request)
case strings.HasPrefix(requestPath, "/nodes"):
return transport.proxyNodeRequest(request)
case strings.HasPrefix(requestPath, "/tasks"):
return transport.proxyTaskRequest(request)
case strings.HasPrefix(requestPath, "/build"):
return transport.proxyBuildRequest(request)
case strings.HasPrefix(requestPath, "/images"):
return transport.proxyImageRequest(request)
case strings.HasPrefix(requestPath, "/v2"):
return transport.proxyAgentRequest(request)
default:
return transport.executeDockerRequest(request)
// from : /containers/{id}/json
// trim to : containers/{id}/json
// pick : [ containers, {id}, json ][0]
// prefix : containers
prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0]
if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil {
return proxyFunc(transport, request, unversionedPath)
}
return transport.executeDockerRequest(request)
}
func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) {
@@ -136,16 +137,14 @@ func (transport *Transport) executeDockerRequest(request *http.Request) (*http.R
}
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
}
return response, err
}
func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, error) {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := strings.TrimPrefix(unversionedPath, "/v2")
switch {
case strings.HasPrefix(requestPath, "/browse"):
@@ -203,8 +202,10 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response,
return transport.executeDockerRequest(r)
}
func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyConfigRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/configs/create":
return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl)
@@ -225,8 +226,10 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res
}
}
func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyContainerRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/containers/create":
return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl)
@@ -261,8 +264,10 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.
}
}
func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyServiceRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/services/create":
return transport.decorateServiceCreationOperation(request)
@@ -292,8 +297,10 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
}
}
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyVolumeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/volumes/create":
return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl)
@@ -309,8 +316,10 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Res
}
}
func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/networks/create":
return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl)
@@ -330,8 +339,10 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Re
}
}
func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/secrets/create":
return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl)
@@ -351,8 +362,8 @@ func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Res
}
}
func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Response, error) {
requestPath := request.URL.Path
func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
// assume /nodes/{id}
if path.Base(requestPath) != "nodes" {
@@ -362,8 +373,10 @@ func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Respo
return transport.executeDockerRequest(request)
}
func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxySwarmRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/swarm":
return transport.rewriteOperation(request, swarmInspectOperation)
default:
@@ -372,8 +385,10 @@ func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Resp
}
}
func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/tasks":
return transport.rewriteOperation(request, transport.taskListOperation)
default:
@@ -382,7 +397,7 @@ func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Respo
}
}
func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) (*http.Response, error) {
err := transport.updateDefaultGitBranch(request)
if err != nil {
return nil, err
@@ -408,8 +423,10 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error
return nil
}
func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
func (transport *Transport) proxyImageRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/images/create":
return transport.replaceRegistryAuthenticationHeader(request)
default:

View File

@@ -8,6 +8,7 @@ import (
"path"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
"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 {
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 {
volume := volumeObject.(map[string]interface{})

View File

@@ -22,7 +22,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
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 {
return nil, err
}

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