Compare commits
222 Commits
fix/EE-659
...
fix/EE-663
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d91008f8 | ||
|
|
cd5f342da0 | ||
|
|
27e309754e | ||
|
|
6ae0a972d4 | ||
|
|
014c491205 | ||
|
|
4ef71f4aca | ||
|
|
5a5a10821d | ||
|
|
9685e260ea | ||
|
|
f8871fcd2a | ||
|
|
6d17d8bc64 | ||
|
|
46c6a0700f | ||
|
|
5f8fd99fe8 | ||
|
|
8a81d95253 | ||
|
|
f22aed34b5 | ||
|
|
e75e6cb7f7 | ||
|
|
14a365045d | ||
|
|
9b6779515e | ||
|
|
88ee1b5d19 | ||
|
|
a45ec9a7b4 | ||
|
|
51605c6442 | ||
|
|
2fe213d864 | ||
|
|
439f13af19 | ||
|
|
2b5ecd3a57 | ||
|
|
a9ead542b3 | ||
|
|
7479302043 | ||
|
|
10d20e5963 | ||
|
|
5a2e6d0e50 | ||
|
|
9068cfd892 | ||
|
|
5560a444e5 | ||
|
|
505a2d5523 | ||
|
|
2463648161 | ||
|
|
48cf27a3b8 | ||
|
|
39fce3e29b | ||
|
|
4f4c685085 | ||
|
|
d177a70c54 | ||
|
|
cf8ec631dd | ||
|
|
ea61f36e5d | ||
|
|
ffc66647f8 | ||
|
|
6623475035 | ||
|
|
0dd12a218b | ||
|
|
5f89d70fd8 | ||
|
|
3ccbd40232 | ||
|
|
7e9dd01265 | ||
|
|
0fb3555a70 | ||
|
|
73ce754316 | ||
|
|
d304f330e8 | ||
|
|
7333598dba | ||
|
|
bb61e73464 | ||
|
|
c15789eb73 | ||
|
|
e7a2b6268e | ||
|
|
688fa3aa78 | ||
|
|
48bc7d0d92 | ||
|
|
d9df58e93a | ||
|
|
37bba18c81 | ||
|
|
40498d8ddd | ||
|
|
b265810b95 | ||
|
|
09837769d7 | ||
|
|
cf1fd17626 | ||
|
|
785f021898 | ||
|
|
80cc9f18b5 | ||
|
|
5e7e91dd6d | ||
|
|
1032b462b4 | ||
|
|
104307b2b2 | ||
|
|
f8c66a31d9 | ||
|
|
2100155ab5 | ||
|
|
de473fc10e | ||
|
|
76e49ed9a8 | ||
|
|
e9ebef15a0 | ||
|
|
6ff4fd3db2 | ||
|
|
d38085a560 | ||
|
|
3cad13388c | ||
|
|
0b62456236 | ||
|
|
c22d280491 | ||
|
|
960d18998f | ||
|
|
3f3db75d85 | ||
|
|
48aab77058 | ||
|
|
7e53d01d0f | ||
|
|
bd271ec5a1 | ||
|
|
8913e75484 | ||
|
|
c95ffa9e2d | ||
|
|
ddb89f71b4 | ||
|
|
45be6c2b45 | ||
|
|
a00cb951bc | ||
|
|
f584bf3830 | ||
|
|
9600eb6fa1 | ||
|
|
d88ef03ddb | ||
|
|
dc9d7ae3f1 | ||
|
|
a3c7eb0ce0 | ||
|
|
d1ba484be1 | ||
|
|
521eb5f114 | ||
|
|
66770bebd4 | ||
|
|
86c4b3059e | ||
|
|
e3a8853212 | ||
|
|
194b6e491d | ||
|
|
a439695248 | ||
|
|
86f1b8df6e | ||
|
|
a5faddc56c | ||
|
|
9c68c6c9f3 | ||
|
|
d99486ee72 | ||
|
|
946166319f | ||
|
|
26bb028ace | ||
|
|
da615afc92 | ||
|
|
2b53bebcb3 | ||
|
|
d336a14e50 | ||
|
|
4ca6292805 | ||
|
|
44ef5bb12a | ||
|
|
bf600f8b11 | ||
|
|
d6d7afddbc | ||
|
|
61642b8df6 | ||
|
|
07de1b2c06 | ||
|
|
bd3440bf3c | ||
|
|
573f003226 | ||
|
|
6e169662c2 | ||
|
|
31658d4028 | ||
|
|
bb02c69d14 | ||
|
|
73307e164b | ||
|
|
9ea5efb6ba | ||
|
|
3cd58cac54 | ||
|
|
1303a08f5a | ||
|
|
3b1d853090 | ||
|
|
a2a4c85f2d | ||
|
|
506ee389e3 | ||
|
|
8635bc9b9c | ||
|
|
447f497506 | ||
|
|
71292a60b1 | ||
|
|
51449490fa | ||
|
|
ae4970f0ed | ||
|
|
e96d5c245d | ||
|
|
f8e3d75797 | ||
|
|
27aaf322b2 | ||
|
|
b77132dbb1 | ||
|
|
c35473f308 | ||
|
|
a570073d12 | ||
|
|
0ad4826fab | ||
|
|
6db7d31554 | ||
|
|
21d67a971d | ||
|
|
8dfa5efa71 | ||
|
|
529750fa21 | ||
|
|
96b1d36280 | ||
|
|
31c5a82749 | ||
|
|
82516620e7 | ||
|
|
d26d5840f1 | ||
|
|
ebd26316bf | ||
|
|
18dbad232e | ||
|
|
ebcc98d5c5 | ||
|
|
e919da3771 | ||
|
|
eda2dd20ee | ||
|
|
385fd95779 | ||
|
|
88185d7f6d | ||
|
|
253cda8cef | ||
|
|
b34afba7cd | ||
|
|
6c70049ecc | ||
|
|
42c2a52a6b | ||
|
|
19a6a5c608 | ||
|
|
d8e374fb76 | ||
|
|
84ca6185dc | ||
|
|
5088634a41 | ||
|
|
f6beedf0d5 | ||
|
|
3caf1ddb7d | ||
|
|
c622f6da4e | ||
|
|
9ec7394124 | ||
|
|
af8fde66b0 | ||
|
|
709315dde5 | ||
|
|
8856bae5c6 | ||
|
|
90451bfd47 | ||
|
|
0c05539dee | ||
|
|
a2a2c6cf3e | ||
|
|
76aa086d79 | ||
|
|
76fdfeaafc | ||
|
|
5932c78b88 | ||
|
|
68f5ca249f | ||
|
|
2d87a8d8c3 | ||
|
|
988d4103d4 | ||
|
|
ce3a1b8ba5 | ||
|
|
6c89d3c0c9 | ||
|
|
6b91fbf7f4 | ||
|
|
4f3f5e57b6 | ||
|
|
6b3f30e32f | ||
|
|
bdeedb4018 | ||
|
|
50946e087c | ||
|
|
7b89b04667 | ||
|
|
f5f84c5fa4 | ||
|
|
437831fa80 | ||
|
|
31f5b42962 | ||
|
|
7a6c872948 | ||
|
|
4bf18b1d65 | ||
|
|
2d25bf4afa | ||
|
|
56ae19c5ab | ||
|
|
cdf9197274 | ||
|
|
901549e8dd | ||
|
|
80b1cd19cb | ||
|
|
c4942de89b | ||
|
|
80d02f9cd1 | ||
|
|
671b22b5d6 | ||
|
|
43e56bf1c0 | ||
|
|
a175619623 | ||
|
|
63c11d9310 | ||
|
|
4c00b72ae3 | ||
|
|
f4db09a534 | ||
|
|
01cd64037f | ||
|
|
a93344386c | ||
|
|
a2195caa10 | ||
|
|
9ad78753bc | ||
|
|
517190e28b | ||
|
|
5ee6efb145 | ||
|
|
a618ee78e4 | ||
|
|
9a1604e775 | ||
|
|
9615e678e6 | ||
|
|
e39c19bcca | ||
|
|
16ae4f8681 | ||
|
|
70deba50ba | ||
|
|
89359dae8c | ||
|
|
97d227be2a | ||
|
|
8a98704111 | ||
|
|
46b2175729 | ||
|
|
1561814fe5 | ||
|
|
2826a4ce39 | ||
|
|
441a8bbbbf | ||
|
|
2248ce0173 | ||
|
|
b640b58371 | ||
|
|
249b6bc628 | ||
|
|
4a10c2bb07 |
@@ -10,6 +10,7 @@ globals:
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- 'plugin:import/typescript'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
@@ -29,6 +30,7 @@ rules:
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/named: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
@@ -43,6 +45,12 @@ rules:
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
no-restricted-imports:
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
@@ -51,6 +59,8 @@ settings:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
typescript: true
|
||||
node: true
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
@@ -75,6 +85,7 @@ overrides:
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
|
||||
rules:
|
||||
import/order:
|
||||
[
|
||||
@@ -108,6 +119,12 @@ overrides:
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
|
||||
'@typescript-eslint/no-restricted-imports':
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
overrides: # allow props spreading for hoc files
|
||||
- files:
|
||||
- app/**/with*.ts{,x}
|
||||
@@ -121,7 +138,13 @@ overrides:
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
'no-alert': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -93,6 +93,10 @@ body:
|
||||
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.20.2'
|
||||
- '2.20.1'
|
||||
- '2.20.0'
|
||||
- '2.19.5'
|
||||
- '2.19.4'
|
||||
- '2.19.3'
|
||||
- '2.19.2'
|
||||
|
||||
129
.github/workflows/ci.yaml
vendored
129
.github/workflows/ci.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- '!release/*'
|
||||
- 'release/*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'develop'
|
||||
@@ -20,9 +20,9 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer
|
||||
NODE_ENV: testing
|
||||
GO_VERSION: 1.21.5
|
||||
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
@@ -30,86 +30,72 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: linux, arch: amd64, version: "" }
|
||||
- { 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: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: '[preparation] checkout the current branch'
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: '[preparation] set up golang'
|
||||
uses: actions/setup-go@v4.0.1
|
||||
uses: actions/setup-go@v5.0.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
- name: '[preparation] cache paths'
|
||||
id: cache-dir-path
|
||||
run: |
|
||||
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
|
||||
- name: '[preparation] cache go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.cache-dir-path.outputs.go-build-dir }}
|
||||
${{ steps.cache-dir-path.outputs.go-mod-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up node.js'
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4.0.1
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: ''
|
||||
- name: '[preparation] cache yarn'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
|
||||
enableCrossOsArchive: true
|
||||
cache: 'yarn'
|
||||
- name: '[preparation] set up qemu'
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set the container image tag'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
|
||||
- name: '[execution] build linux & windows portainer binaries'
|
||||
run: |
|
||||
export YARN_VERSION=$(yarn --version)
|
||||
export WEBPACK_VERSION=$(yarn list webpack --depth=0 | grep webpack | awk -F@ '{print $2}')
|
||||
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||
|
||||
NODE_ENV="testing"
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
NODE_ENV="production"
|
||||
fi
|
||||
|
||||
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
@@ -121,35 +107,70 @@ jobs:
|
||||
else
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
fi
|
||||
fi
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
build_manifests:
|
||||
runs-on: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
needs: [build_images]
|
||||
steps:
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker version && docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[execution] build and push manifests'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
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"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
fi
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -18,7 +18,7 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.5
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/nightly-security-scan.yml
vendored
2
.github/workflows/nightly-security-scan.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.5
|
||||
GO_VERSION: 1.21.9
|
||||
|
||||
jobs:
|
||||
client-dependencies:
|
||||
|
||||
2
.github/workflows/pr-security.yml
vendored
2
.github/workflows/pr-security.yml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- '.github/workflows/pr-security.yml'
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.3
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
10
.github/workflows/test.yaml
vendored
10
.github/workflows/test.yaml
vendored
@@ -1,17 +1,25 @@
|
||||
name: Test
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.5
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
test-client:
|
||||
|
||||
2
.github/workflows/validate-openapi-spec.yaml
vendored
2
.github/workflows/validate-openapi-spec.yaml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.5
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
|
||||
@@ -82,14 +82,8 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = datastore.BackupTo(backupWriter); err != nil {
|
||||
return err
|
||||
}
|
||||
return backupWriter.Close()
|
||||
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
|
||||
return err
|
||||
}
|
||||
|
||||
func encrypt(path string, passphrase string) (string, error) {
|
||||
|
||||
@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
||||
if password != "" {
|
||||
archive, err = decrypt(archive, password)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to decrypt the archive")
|
||||
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package build
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Variables to be set during the build time
|
||||
var BuildNumber string
|
||||
var ImageTag string
|
||||
var NodejsVersion string
|
||||
var YarnVersion string
|
||||
var WebpackVersion string
|
||||
var GoVersion string
|
||||
var GoVersion string = runtime.Version()
|
||||
var GitCommit string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -28,12 +29,17 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
require.NoError(t, http.Serve(ln, mux))
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
|
||||
s.getTunnelDetails(endpointID)
|
||||
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
require.Error(t, s.pingAgent(endpointID))
|
||||
require.NoError(t, srv.Shutdown(context.Background()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
|
||||
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
}
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
@@ -42,6 +42,13 @@ func setLoggingMode(mode string) {
|
||||
TimeFormat: "2006/01/02 03:04PM",
|
||||
FormatMessage: formatMessage,
|
||||
})
|
||||
case "NOCOLOR":
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006/01/02 03:04PM",
|
||||
FormatMessage: formatMessage,
|
||||
NoColor: true,
|
||||
})
|
||||
case "JSON":
|
||||
log.Logger = log.Output(os.Stderr)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -42,6 +43,8 @@ import (
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
@@ -457,19 +460,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 +484,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
||||
pendingActionsService.RegisterHandler(actions.DeleteK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
|
||||
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||
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 +586,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 +660,7 @@ func main() {
|
||||
Msg("starting Portainer")
|
||||
|
||||
err := server.Start()
|
||||
|
||||
log.Info().Err(err).Msg("HTTP server exited")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,216 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
// NOTE: has to go with what is considered to be a simplistic in that it omits any
|
||||
// authentication of the encrypted data.
|
||||
// Person with better knowledge is welcomed to improve it.
|
||||
// sourced from https://golang.org/src/crypto/cipher/example_test.go
|
||||
const (
|
||||
// AES GCM settings
|
||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
// Argon2 settings
|
||||
// Recommded settings lower memory hardware according to current OWASP recommendations
|
||||
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
argon2MemoryCost = 12 * 1024
|
||||
argon2TimeCost = 3
|
||||
argon2Threads = 1
|
||||
argon2KeyLength = 32
|
||||
)
|
||||
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
err := aesEncryptGCM(input, output, passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||
// Copy the input to the output, encrypting as we go.
|
||||
if _, err := io.Copy(writer, input); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
||||
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Read file header to determine how it was encrypted
|
||||
inputReader := bufio.NewReader(input)
|
||||
header, err := inputReader.Peek(len(aesGcmHeader))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
||||
}
|
||||
|
||||
if string(header) == aesGcmHeader {
|
||||
reader, err := aesDecryptGCM(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// Use the previous decryption routine which has no header (to support older archives)
|
||||
reader, err := aesDecryptOFB(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
|
||||
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// Derive key using argon2 with a random salt
|
||||
salt := make([]byte, 16) // 16 bytes salt
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce, err := NewRandomNonce(aesgcm.NonceSize())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the header
|
||||
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write nonce and salt to the output file
|
||||
if _, err := output.Write(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(nonce.Value()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buffer for reading plaintext blocks
|
||||
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
|
||||
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
|
||||
|
||||
// Encrypt plaintext in blocks
|
||||
for {
|
||||
n, err := io.ReadFull(input, buf)
|
||||
if n == 0 {
|
||||
break // end of plaintext input
|
||||
}
|
||||
|
||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
||||
|
||||
_, err = output.Write(ciphertext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce.Increment()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
|
||||
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Reader & verify header
|
||||
header := make([]byte, len(aesGcmHeader))
|
||||
if _, err := io.ReadFull(input, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(header) != aesGcmHeader {
|
||||
return nil, fmt.Errorf("invalid header")
|
||||
}
|
||||
|
||||
// Read salt
|
||||
salt := make([]byte, 16) // Salt size
|
||||
if _, err := io.ReadFull(input, salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
|
||||
// Initialize AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create GCM mode with the cipher block
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read nonce from the input reader
|
||||
nonce := NewNonce(aesgcm.NonceSize())
|
||||
if err := nonce.Read(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize a buffer to store decrypted data
|
||||
buf := bytes.Buffer{}
|
||||
plaintext := make([]byte, aesGcmBlockSize)
|
||||
|
||||
// Decrypt the ciphertext in blocks
|
||||
for {
|
||||
// Read a block of ciphertext from the input reader
|
||||
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
|
||||
n, err := io.ReadFull(input, ciphertextBlock)
|
||||
if n == 0 {
|
||||
break // end of ciphertext
|
||||
}
|
||||
|
||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the block of ciphertext
|
||||
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = buf.Write(plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce.Increment()
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
||||
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
@@ -59,11 +223,9 @@ func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
reader := &cipher.StreamReader{S: stream, R: input}
|
||||
|
||||
return reader, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package crypto
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,7 +10,19 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
const passphrase = "passphrase"
|
||||
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
@@ -18,17 +31,99 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
@@ -57,7 +152,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1024 * 50)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -96,7 +191,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1034)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -117,11 +212,6 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
|
||||
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
}
|
||||
|
||||
61
api/crypto/nonce.go
Normal file
61
api/crypto/nonce.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Nonce struct {
|
||||
val []byte
|
||||
}
|
||||
|
||||
func NewNonce(size int) *Nonce {
|
||||
return &Nonce{val: make([]byte, size)}
|
||||
}
|
||||
|
||||
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
||||
// This ensures there are plenty of nonce values availble before rolling over
|
||||
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
||||
func NewRandomNonce(size int) (*Nonce, error) {
|
||||
randomBytes := 1
|
||||
if size <= randomBytes {
|
||||
return nil, errors.New("nonce size must be greater than the number of random bytes")
|
||||
}
|
||||
|
||||
randomPart := make([]byte, randomBytes)
|
||||
if _, err := rand.Read(randomPart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zeroPart := make([]byte, size-randomBytes)
|
||||
nonceVal := append(randomPart, zeroPart...)
|
||||
return &Nonce{val: nonceVal}, nil
|
||||
}
|
||||
|
||||
func (n *Nonce) Read(stream io.Reader) error {
|
||||
_, err := io.ReadFull(stream, n.val)
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *Nonce) Value() []byte {
|
||||
return n.val
|
||||
}
|
||||
|
||||
func (n *Nonce) Increment() error {
|
||||
// Start incrementing from the least significant byte
|
||||
for i := len(n.val) - 1; i >= 0; i-- {
|
||||
// Increment the current byte
|
||||
n.val[i]++
|
||||
|
||||
// Check for overflow
|
||||
if n.val[i] != 0 {
|
||||
// No overflow, nonce is successfully incremented
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, it means the nonce has overflowed
|
||||
return errors.New("nonce overflow")
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package dataservices
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
)
|
||||
@@ -46,7 +44,7 @@ type (
|
||||
MigrateData() error
|
||||
Rollback(force bool) error
|
||||
CheckCurrentEdition() error
|
||||
BackupTo(w io.Writer) error
|
||||
Backup(path string) (string, error)
|
||||
Export(filename string) (err error)
|
||||
|
||||
DataStoreTx
|
||||
@@ -73,8 +71,9 @@ type (
|
||||
}
|
||||
|
||||
PendingActionsService interface {
|
||||
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
|
||||
BaseCRUD[portainer.PendingAction, portainer.PendingActionID]
|
||||
GetNextIdentifier() int
|
||||
DeleteByEndpointID(ID portainer.EndpointID) error
|
||||
}
|
||||
|
||||
// EdgeStackService represents a service to manage Edge stacks
|
||||
|
||||
@@ -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 (
|
||||
@@ -12,11 +14,11 @@ const (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
dataservices.BaseDataService[portainer.PendingActions, portainer.PendingActionsID]
|
||||
dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]
|
||||
}
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]
|
||||
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
|
||||
}
|
||||
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
@@ -26,28 +28,34 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}
|
||||
|
||||
return &Service{
|
||||
BaseDataService: dataservices.BaseDataService[portainer.PendingActions, portainer.PendingActionsID]{
|
||||
BaseDataService: dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]{
|
||||
Bucket: BucketName,
|
||||
Connection: connection,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) Create(config *portainer.PendingActions) error {
|
||||
func (s Service) Create(config *portainer.PendingAction) error {
|
||||
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return s.Tx(tx).Create(config)
|
||||
})
|
||||
}
|
||||
|
||||
func (s Service) Update(ID portainer.PendingActionsID, config *portainer.PendingActions) error {
|
||||
func (s Service) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
|
||||
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return s.Tx(tx).Update(ID, config)
|
||||
})
|
||||
}
|
||||
|
||||
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]{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.Connection,
|
||||
Tx: tx,
|
||||
@@ -55,19 +63,42 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
}
|
||||
}
|
||||
|
||||
func (s ServiceTx) Create(config *portainer.PendingActions) error {
|
||||
func (s ServiceTx) Create(config *portainer.PendingAction) error {
|
||||
return s.Tx.CreateObject(BucketName, func(id uint64) (int, interface{}) {
|
||||
config.ID = portainer.PendingActionsID(id)
|
||||
config.ID = portainer.PendingActionID(id)
|
||||
config.CreatedAt = time.Now().Unix()
|
||||
|
||||
return int(config.ID), config
|
||||
})
|
||||
}
|
||||
|
||||
func (s ServiceTx) Update(ID portainer.PendingActionsID, config *portainer.PendingActions) error {
|
||||
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
|
||||
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)
|
||||
|
||||
@@ -9,12 +9,19 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (store *Store) Backup() (string, error) {
|
||||
// Backup takes an optional output path and creates a backup of the database.
|
||||
// The database connection is stopped before running the backup to avoid any
|
||||
// corruption and if a path is not given a default is used.
|
||||
// The path or an error are returned.
|
||||
func (store *Store) Backup(path string) (string, error) {
|
||||
if err := store.createBackupPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
backupFilename := store.backupFilename()
|
||||
if path != "" {
|
||||
backupFilename = path
|
||||
}
|
||||
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
|
||||
|
||||
// Close the store before backing up
|
||||
@@ -69,7 +76,7 @@ func (store *Store) RestoreFromFile(backupFilename string) error {
|
||||
func (store *Store) createBackupPath() error {
|
||||
backupDir := path.Join(store.connection.GetStorePath(), "backups")
|
||||
if exists, _ := store.fileService.FileExists(backupDir); !exists {
|
||||
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return fmt.Errorf("unable to create backup folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestBackup(t *testing.T) {
|
||||
SchemaVersion: portainer.APIVersion,
|
||||
}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
store.Backup()
|
||||
store.Backup("")
|
||||
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
@@ -55,7 +55,7 @@ func TestRestore(t *testing.T) {
|
||||
updateEdition(store, portainer.PortainerCE)
|
||||
updateVersion(store, "2.4")
|
||||
|
||||
store.Backup()
|
||||
store.Backup("")
|
||||
updateVersion(store, "2.16")
|
||||
testVersion(store, "2.16", t)
|
||||
store.Restore()
|
||||
@@ -68,7 +68,7 @@ func TestRestore(t *testing.T) {
|
||||
// override and set initial db version and edition
|
||||
updateEdition(store, portainer.PortainerCE)
|
||||
updateVersion(store, "2.4")
|
||||
store.Backup()
|
||||
store.Backup("")
|
||||
updateVersion(store, "2.14")
|
||||
updateVersion(store, "2.16")
|
||||
testVersion(store, "2.16", t)
|
||||
|
||||
@@ -31,7 +31,7 @@ func (store *Store) Open() (newStore bool, err error) {
|
||||
}
|
||||
|
||||
if encryptionReq {
|
||||
backupFilename, err := store.Backup()
|
||||
backupFilename, err := store.Backup("")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (store *Store) MigrateData() error {
|
||||
}
|
||||
|
||||
// before we alter anything in the DB, create a backup
|
||||
_, err = store.Backup()
|
||||
_, err = store.Backup("")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "while backing up database")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +132,6 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
|
||||
|
||||
// Rollback to a pre-upgrade backup copy/snapshot of portainer.db
|
||||
func (store *Store) connectionRollback(force bool) error {
|
||||
|
||||
if !force {
|
||||
confirmed, err := cli.Confirm("Are you sure you want to rollback your database to the previous backup?")
|
||||
if err != nil || !confirmed {
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestRollback(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, false, false)
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
_, err := store.Backup()
|
||||
_, err := store.Backup("")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestRollback(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
_, err := store.Backup()
|
||||
_, err := store.Backup("")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
|
||||
os.WriteFile(
|
||||
gotPath,
|
||||
gotJSON,
|
||||
0600,
|
||||
0o600,
|
||||
)
|
||||
t.Errorf(
|
||||
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PostInitMigrator struct {
|
||||
kubeFactory *cli.ClientFactory
|
||||
dockerFactory *dockerclient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
|
||||
return &PostInitMigrator{
|
||||
kubeFactory: kubeFactory,
|
||||
dockerFactory: dockerFactory,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (migrator *PostInitMigrator) PostInitMigrate() error {
|
||||
if err := migrator.PostInitMigrateIngresses(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrator.PostInitMigrateGPUs()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
|
||||
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
// Early exit if we do not need to migrate!
|
||||
if !endpoints[i].PostInitMigrations.MigrateIngresses {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
|
||||
// If there's an error getting the containers, we'll log it and move on
|
||||
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
|
||||
environments, err := migrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failure getting endpoints")
|
||||
return
|
||||
}
|
||||
|
||||
for i := range environments {
|
||||
if environments[i].Type == portainer.DockerEnvironment {
|
||||
// // Early exit if we do not need to migrate!
|
||||
if !environments[i].PostInitMigrations.MigrateGPUs {
|
||||
return
|
||||
}
|
||||
|
||||
// set the MigrateGPUs flag to false so we don't run this again
|
||||
environments[i].PostInitMigrations.MigrateGPUs = false
|
||||
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
|
||||
|
||||
// create a docker client
|
||||
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
|
||||
return
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
// get all containers
|
||||
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
|
||||
containersLoop:
|
||||
for _, container := range containers {
|
||||
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
|
||||
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to inspect container")
|
||||
return
|
||||
}
|
||||
|
||||
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
|
||||
for _, deviceRequest := range deviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" {
|
||||
environments[i].EnableGPUManagement = true
|
||||
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
|
||||
|
||||
break containersLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,29 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
|
||||
|
||||
return migrator.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
// In PortainerCE the resource overcommit option should always be true across all endpoints
|
||||
func (migrator *Migrator) updateResourceOverCommitToDB110() error {
|
||||
log.Info().Msg("updating resource overcommit setting to true")
|
||||
|
||||
endpoints, err := migrator.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
|
||||
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true
|
||||
|
||||
err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
32
api/datastore/migrator/migrate_dbversion111.go
Normal file
32
api/datastore/migrator/migrate_dbversion111.go
Normal 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
|
||||
}
|
||||
33
api/datastore/migrator/migrate_dbversion130.go
Normal file
33
api/datastore/migrator/migrate_dbversion130.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (migrator *Migrator) migratePendingActionsDataForDB130() error {
|
||||
log.Info().Msg("Migrating pending actions data")
|
||||
|
||||
pendingActions, err := migrator.pendingActionsService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pa := range pendingActions {
|
||||
actionData, err := json.Marshal(pa.ActionData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pa.ActionData = string(actionData)
|
||||
|
||||
// Update the pending action
|
||||
err = migrator.pendingActionsService.Update(pa.ID, &pa)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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()
|
||||
@@ -230,9 +234,16 @@ func (m *Migrator) initMigrations() {
|
||||
)
|
||||
m.addMigrations("2.20",
|
||||
m.updateAppTemplatesVersionForDB110,
|
||||
m.updateResourceOverCommitToDB110,
|
||||
)
|
||||
m.addMigrations("2.20.2",
|
||||
m.cleanPendingActionsForDeletedEndpointsForDB111,
|
||||
)
|
||||
m.addMigrations("2.22.0",
|
||||
m.migratePendingActionsDataForDB130,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
// Add new migrations above...
|
||||
// One function per migration, each versions migration funcs in the same file.
|
||||
}
|
||||
|
||||
|
||||
98
api/datastore/pendingactions_test.go
Normal file
98
api/datastore/pendingactions_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
)
|
||||
|
||||
type cleanNAPWithOverridePolicies struct {
|
||||
EndpointGroupID portainer.EndpointGroupID
|
||||
}
|
||||
|
||||
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
|
||||
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
|
||||
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
defer store.Close()
|
||||
|
||||
gid := portainer.EndpointGroupID(1)
|
||||
|
||||
testData := []struct {
|
||||
Name string
|
||||
PendingAction portainer.PendingAction
|
||||
Expected any
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
Name: "test actiondata with EndpointGroupID 1",
|
||||
PendingAction: handlers.NewCleanNAPWithOverridePolicies(
|
||||
1,
|
||||
&gid,
|
||||
),
|
||||
Expected: portainer.EndpointGroupID(1),
|
||||
},
|
||||
{
|
||||
Name: "test actionData nil",
|
||||
PendingAction: handlers.NewCleanNAPWithOverridePolicies(
|
||||
2,
|
||||
nil,
|
||||
),
|
||||
Expected: nil,
|
||||
},
|
||||
{
|
||||
Name: "test actionData empty and expected error",
|
||||
PendingAction: portainer.PendingAction{
|
||||
EndpointID: 2,
|
||||
Action: actions.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 == actions.CleanNAPWithOverridePolicies {
|
||||
var payload cleanNAPWithOverridePolicies
|
||||
|
||||
err := endpointPendingAction.UnmarshallActionData(&payload)
|
||||
|
||||
if d.Err && err == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if d.Expected == nil && payload.EndpointGroupID != 0 {
|
||||
t.Errorf("expected nil, got %d", payload.EndpointGroupID)
|
||||
}
|
||||
|
||||
if d.Expected != nil {
|
||||
expected := d.Expected.(portainer.EndpointGroupID)
|
||||
if d.Expected != nil && expected != payload.EndpointGroupID {
|
||||
t.Errorf("expected EndpointGroupID %d, got %d", expected, payload.EndpointGroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
store.PendingActions().Delete(d.PendingAction.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
203
api/datastore/postinit/migrate_post_init.go
Normal file
203
api/datastore/postinit/migrate_post_init.go
Normal 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.PendingAction{
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.22.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,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"
|
||||
)
|
||||
@@ -93,11 +93,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 +165,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
|
||||
|
||||
@@ -119,7 +119,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")
|
||||
@@ -135,7 +135,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
c.sr.push(func() {
|
||||
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
|
||||
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
|
||||
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{})
|
||||
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -164,14 +164,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()
|
||||
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
|
||||
handler = gorillacsrf.Protect(
|
||||
[]byte(token),
|
||||
gorillacsrf.Path("/"),
|
||||
gorillacsrf.Secure(false),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler), nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/slices"
|
||||
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"
|
||||
)
|
||||
|
||||
// @id CustomTemplateList
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param type query []int true "Template types" Enums(1,2,3)
|
||||
// @param edge query boolean false "Filter by edge templates"
|
||||
// @success 200 {array} portainer.CustomTemplate "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /custom_templates [get]
|
||||
@@ -30,6 +34,8 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid Custom template type", err)
|
||||
}
|
||||
|
||||
edge := retrieveEdgeParam(r)
|
||||
|
||||
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
|
||||
@@ -63,9 +69,37 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
customTemplates = filterByType(customTemplates, templateTypes)
|
||||
|
||||
if edge != nil {
|
||||
customTemplates = slices.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
||||
return customTemplate.EdgeTemplate == *edge
|
||||
})
|
||||
}
|
||||
|
||||
for i := range customTemplates {
|
||||
customTemplate := &customTemplates[i]
|
||||
if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil {
|
||||
customTemplate.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, customTemplates)
|
||||
}
|
||||
|
||||
func retrieveEdgeParam(r *http.Request) *bool {
|
||||
var edge *bool
|
||||
edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true)
|
||||
if edgeParam != "" {
|
||||
edgeVal, err := strconv.ParseBool(edgeParam)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed parsing edge param")
|
||||
return nil
|
||||
}
|
||||
|
||||
edge = &edgeVal
|
||||
}
|
||||
return edge
|
||||
}
|
||||
|
||||
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -135,6 +135,11 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
}
|
||||
|
||||
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
|
||||
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
||||
delete(stack.Status, environmentId)
|
||||
return
|
||||
}
|
||||
|
||||
environmentStatus, ok := stack.Status[environmentId]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/tag"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -156,11 +157,7 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
if err != nil {
|
||||
// Update flag with endpoint and continue
|
||||
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
|
||||
err := handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpointID,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: endpointGroupID,
|
||||
})
|
||||
err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -17,6 +18,26 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type DeleteMultiplePayload struct {
|
||||
Endpoints []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DeleteCluster bool `json:"deleteCluster"`
|
||||
} `json:"environments"`
|
||||
}
|
||||
|
||||
func (payload *DeleteMultiplePayload) Validate(r *http.Request) error {
|
||||
if payload == nil || len(payload.Endpoints) == 0 {
|
||||
return fmt.Errorf("invalid request payload; you must provide a list of nodes to delete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DeleteMultipleResp struct {
|
||||
Name string `json:"name"`
|
||||
Err error `json:"err"`
|
||||
}
|
||||
|
||||
// @id EndpointDelete
|
||||
// @summary Remove an environment(endpoint)
|
||||
// @description Remove an environment(endpoint).
|
||||
@@ -31,6 +52,7 @@ import (
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints/{id} [delete]
|
||||
// @deprecated
|
||||
func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
@@ -62,6 +84,53 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
// @id EndpointDeleteMultiple
|
||||
// @summary Remove multiple environment(endpoint)s
|
||||
// @description Remove multiple environment(endpoint)s.
|
||||
// @description **Access policy**: administrator
|
||||
// @tags endpoints
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body DeleteMultiplePayload true "List of endpoints to delete"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints/remove [post]
|
||||
func (handler *Handler) endpointDeleteMultiple(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var p DeleteMultiplePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &p)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
var resps []DeleteMultipleResp
|
||||
for _, e := range p.Endpoints {
|
||||
// Demo endpoints cannot be deleted.
|
||||
if handler.demoService.IsDemoEnvironment(portainer.EndpointID(e.ID)) {
|
||||
resps = append(resps, DeleteMultipleResp{
|
||||
Name: e.Name,
|
||||
Err: httperrors.ErrNotAvailableInDemo,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt deletion.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.deleteEndpoint(
|
||||
tx,
|
||||
portainer.EndpointID(e.ID),
|
||||
e.DeleteCluster,
|
||||
)
|
||||
})
|
||||
resps = append(resps, DeleteMultipleResp{Name: e.Name, Err: err})
|
||||
}
|
||||
return response.JSON(w, resps)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -179,6 +248,12 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
}
|
||||
|
||||
// delete the pending actions
|
||||
err = tx.PendingActions().DeleteByEndpointID(endpoint.ID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msgf("Unable to delete pending actions")
|
||||
}
|
||||
|
||||
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete the environment from the database", err)
|
||||
|
||||
@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
|
||||
handler.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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -69,6 +70,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 {
|
||||
@@ -265,11 +267,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil)
|
||||
if err != nil {
|
||||
handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpoint.ID,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: nil,
|
||||
})
|
||||
handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil))
|
||||
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,11 +334,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)
|
||||
}
|
||||
@@ -622,9 +627,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
|
||||
}
|
||||
|
||||
@@ -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/remove",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteMultiple))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/snapshot",
|
||||
|
||||
@@ -85,7 +85,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.20.0
|
||||
// @version 2.22.0
|
||||
// @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"):
|
||||
|
||||
@@ -38,19 +38,20 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
kubeClusterAccessService: kubeClusterAccessService,
|
||||
}
|
||||
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"),
|
||||
bouncer.AuthenticatedAccess)
|
||||
|
||||
// `helm list -o json`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmList)).Methods(http.MethodGet)
|
||||
|
||||
// `helm delete RELEASE_NAME`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete)
|
||||
httperror.LoggerHandler(h.helmDelete)).Methods(http.MethodDelete)
|
||||
|
||||
// `helm install [NAME] [CHART] flags`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost)
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// Deprecated
|
||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
||||
@@ -69,12 +70,14 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
h.Handle("/templates/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmRepoSearch)).Methods(http.MethodGet)
|
||||
|
||||
// helm show [COMMAND] [CHART] [REPO] flags
|
||||
h.Handle("/templates/helm/{command:chart|values|readme}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmShow)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -126,7 +126,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||
if !ok {
|
||||
return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||
if ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -213,7 +213,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli)
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
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/handlers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -89,17 +89,9 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) er
|
||||
}
|
||||
|
||||
if len(failedNamespaces) > 0 {
|
||||
handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpointId,
|
||||
Action: pendingactions.DeletePortainerK8sRegistrySecrets,
|
||||
|
||||
// When extracting the data, this is the type we need to pull out
|
||||
// i.e. pendingactions.DeletePortainerK8sRegistrySecretsData
|
||||
ActionData: pendingactions.DeletePortainerK8sRegistrySecretsData{
|
||||
RegistryID: registry.ID,
|
||||
Namespaces: failedNamespaces,
|
||||
},
|
||||
})
|
||||
handler.PendingActionsService.Create(
|
||||
handlers.NewDeleteK8sRegistrySecrets(portainer.EndpointID(endpointId), registry.ID, failedNamespaces),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type stackListOperationFilters struct {
|
||||
// @description List all stacks based on the current user authorizations.
|
||||
// @description Will return all stacks if using an administrator account otherwise it
|
||||
// @description will only return the list of stacks the user have access to.
|
||||
// @description Limited stacks will not be returned by this endpoint.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
@@ -91,25 +92,55 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return response.JSON(w, stacks)
|
||||
}
|
||||
|
||||
// filterStacks refines a collection of Stack instances using specified criteria.
|
||||
// This function examines the provided filters: EndpointID, SwarmID, and IncludeOrphanedStacks.
|
||||
// - If both EndpointID is zero and SwarmID is an empty string, the function directly returns the original stack list without any modifications.
|
||||
// - If either filter is specified, it proceeds to selectively include stacks that match the criteria.
|
||||
|
||||
// Key Points on Business Logic:
|
||||
// 1. Determining Inclusion of Orphaned Stacks:
|
||||
// - The decision to include orphaned stacks is influenced by the user's role and usually set by the client (UI).
|
||||
// - Administrators or environment administrators can include orphaned stacks by setting IncludeOrphanedStacks to true, reflecting their broader access rights.
|
||||
// - For non-administrative users, this is typically set to false, limiting their visibility to only stacks within their purview.
|
||||
|
||||
// 2. Inclusion Criteria for Orphaned Stacks:
|
||||
// - When IncludeOrphanedStacks is true and an EndpointID is specified (not zero), the function selects:
|
||||
// a) Stacks linked to the specified EndpointID.
|
||||
// b) Orphaned stacks that don't have a naming conflict with any stack associated with the EndpointID.
|
||||
// - This approach is designed to avoid name conflicts within Docker Compose, which restricts the creation of multiple stacks with the same name.
|
||||
|
||||
// 3. Type Matching for Orphaned Stacks:
|
||||
// - The function ensures that orphaned stacks are compatible with the environment's stack type (compose or swarm).
|
||||
// - It filters out orphaned swarm stacks in Docker standalone environments
|
||||
// - It filters out orphaned standalone stack in Docker swarm environments
|
||||
// - This ensures that re-association respects the constraints of the environment and stack type.
|
||||
|
||||
// The outcome is a new list of stacks that align with these filtering and business logic criteria.
|
||||
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
|
||||
if filters.EndpointID == 0 && filters.SwarmID == "" {
|
||||
return stacks
|
||||
}
|
||||
|
||||
filteredStacks := make([]portainer.Stack, 0, len(stacks))
|
||||
uniqueStackNames := make(map[string]struct{})
|
||||
for _, stack := range stacks {
|
||||
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
|
||||
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
uniqueStackNames[stack.Name] = struct{}{}
|
||||
}
|
||||
if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
uniqueStackNames[stack.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
|
||||
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
|
||||
if _, exists := uniqueStackNames[stack.Name]; !exists {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
api/http/handler/stacks/stack_list_test.go
Normal file
74
api/http/handler/stacks/stack_list_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterStacks(t *testing.T) {
|
||||
t.Run("filter stacks against particular endpoint and all orphaned stacks", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
|
||||
t.Run("filter unique stacks against particular endpoint and all orphaned stacks and an orphaned stack has the same name with normal stack", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
|
||||
t.Run("only filter stacks against particular endpoint and no orphaned stacks", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: false}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
}
|
||||
|
||||
func isEqualStacks(t *testing.T, expectStacks, actualStacks []portainer.Stack) {
|
||||
expectStackIDs := make([]int, len(expectStacks))
|
||||
for i, stack := range expectStacks {
|
||||
expectStackIDs[i] = int(stack.ID)
|
||||
}
|
||||
sort.Ints(expectStackIDs)
|
||||
|
||||
actualStackIDs := make([]int, len(actualStacks))
|
||||
for i, stack := range actualStacks {
|
||||
actualStackIDs[i] = int(stack.ID)
|
||||
}
|
||||
sort.Ints(actualStackIDs)
|
||||
|
||||
assert.Equal(t, expectStackIDs, actualStackIDs)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,8 @@ type stackGitRedployPayload struct {
|
||||
Prune bool
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
|
||||
StackName string
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -44,7 +46,7 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack."
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy a stack"
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -136,6 +138,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Type == portainer.KubernetesStack {
|
||||
stack.Name = payload.StackName
|
||||
}
|
||||
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if payload.RepositoryAuthentication {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,7 +47,7 @@ func NewHandler(bouncer security.BouncerService,
|
||||
authenticatedRouter := router.PathPrefix("/").Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
authenticatedRouter.Handle("/version", http.HandlerFunc(h.version)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/version", httperror.LoggerHandler(h.version)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/nodes", httperror.LoggerHandler(h.systemNodesCount)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/info", httperror.LoggerHandler(h.systemInfo)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ package system
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
@@ -32,6 +35,8 @@ type BuildInfo struct {
|
||||
YarnVersion string
|
||||
WebpackVersion string
|
||||
GoVersion string
|
||||
GitCommit string
|
||||
Env []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// @id systemVersion
|
||||
@@ -44,7 +49,11 @@ type BuildInfo struct {
|
||||
// @produce json
|
||||
// @success 200 {object} versionResponse "Success"
|
||||
// @router /system/version [get]
|
||||
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
|
||||
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
isAdmin, err := security.IsAdmin(r)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access Portainer", err)
|
||||
}
|
||||
|
||||
result := &versionResponse{
|
||||
ServerVersion: portainer.APIVersion,
|
||||
@@ -57,16 +66,21 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
|
||||
YarnVersion: build.YarnVersion,
|
||||
WebpackVersion: build.WebpackVersion,
|
||||
GoVersion: build.GoVersion,
|
||||
GitCommit: build.GitCommit,
|
||||
},
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
result.Build.Env = os.Environ()
|
||||
}
|
||||
|
||||
latestVersion := GetLatestVersion()
|
||||
if HasNewerVersion(portainer.APIVersion, latestVersion) {
|
||||
result.UpdateAvailable = true
|
||||
result.LatestVersion = latestVersion
|
||||
}
|
||||
|
||||
response.JSON(w, &result)
|
||||
return response.JSON(w, &result)
|
||||
}
|
||||
|
||||
func GetLatestVersion() string {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,7 +38,7 @@ 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -20,9 +21,6 @@ type userAccessTokenCreatePayload struct {
|
||||
}
|
||||
|
||||
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Password) {
|
||||
return errors.New("invalid password: cannot be empty")
|
||||
}
|
||||
if govalidator.IsNull(payload.Description) {
|
||||
return errors.New("invalid description: cannot be empty")
|
||||
}
|
||||
@@ -44,6 +42,7 @@ type accessTokenResponse struct {
|
||||
// @summary Generate an API key for a user
|
||||
// @description Generates an API key for a user.
|
||||
// @description Only the calling user can generate a token for themselves.
|
||||
// @description Password is required only for internal authentication.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security jwt
|
||||
@@ -51,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"
|
||||
@@ -60,8 +59,13 @@ type accessTokenResponse struct {
|
||||
// @router /users/{id}/tokens [post]
|
||||
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
|
||||
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required"))
|
||||
jwt, _ := handler.bouncer.CookieAuthLookup(r)
|
||||
if jwt == nil {
|
||||
jwt, _ = handler.bouncer.JWTAuthLookup(r)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Authentication required"))
|
||||
}
|
||||
|
||||
var payload userAccessTokenCreatePayload
|
||||
@@ -89,9 +93,21 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
return httperror.InternalServerError("Unable to determine the authentication method", err)
|
||||
}
|
||||
|
||||
if internalAuth {
|
||||
// Internal auth requires the password field and must not be empty
|
||||
if govalidator.IsNull(payload.Password) {
|
||||
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||
@@ -99,6 +115,20 @@ 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 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// otherwise determine the auth method from the settings
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
|
||||
}
|
||||
|
||||
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body))
|
||||
is.Equal(`{"message":"Auth not supported","details":"Authentication required"}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
|
||||
// @tags webhooks
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param filters query webhookListOperationFilters false "Filters"
|
||||
// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
|
||||
// @success 200 {array} portainer.Webhook
|
||||
// @failure 400
|
||||
// @failure 500
|
||||
|
||||
@@ -102,7 +102,10 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer websocketConn.Close()
|
||||
defer func() {
|
||||
time.Sleep(10 * time.Second)
|
||||
websocketConn.Close()
|
||||
}()
|
||||
|
||||
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
|
||||
|
||||
proxy := &dockerLocalProxy{}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ type (
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
}
|
||||
)
|
||||
|
||||
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *ProxyFactory {
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
|
||||
return &ProxyFactory{
|
||||
dataStore: dataStore,
|
||||
signatureService: signatureService,
|
||||
@@ -36,6 +37,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
gitService: gitService,
|
||||
snapshotService: snapshotService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,24 @@ type (
|
||||
)
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager {
|
||||
func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
|
||||
return &Manager{
|
||||
endpointProxies: cmap.New(),
|
||||
k8sClientFactory: kubernetesClientFactory,
|
||||
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
|
||||
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
}
|
||||
|
||||
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
|
||||
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,6 +55,9 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
|
||||
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewAgentProxy(endpoint)
|
||||
}
|
||||
|
||||
@@ -74,5 +84,8 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
|
||||
|
||||
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
|
||||
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewGitlabProxy(url)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -27,6 +29,7 @@ type (
|
||||
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
}
|
||||
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
@@ -280,7 +283,7 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
for _, lookup := range tokenLookups {
|
||||
resultToken, err := lookup(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized)
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +319,7 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -332,7 +335,7 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -366,7 +369,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
|
||||
Role: user.Role,
|
||||
}
|
||||
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
log.Debug().Err(err).Msg("Failed to generate token")
|
||||
return nil, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
|
||||
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
|
||||
|
||||
@@ -61,7 +61,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/internal/ssl"
|
||||
"github.com/portainer/portainer/api/internal/upgrade"
|
||||
k8s "github.com/portainer/portainer/api/kubernetes"
|
||||
@@ -382,7 +381,8 @@ func (server *Server) Start() error {
|
||||
|
||||
go shutdown(server.ShutdownCtx, httpsServer)
|
||||
|
||||
go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
|
||||
// Temporarily disable for EE-6905 until we have a solution for the snapshotter
|
||||
// go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
|
||||
|
||||
return httpsServer.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
@@ -8,3 +8,16 @@ func Map[T, U any](s []T, f func(T) U) []U {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true
|
||||
func Filter[T any](s []T, predicate func(T) bool) []T {
|
||||
n := 0
|
||||
for _, v := range s {
|
||||
if predicate(v) {
|
||||
s[n] = v
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
131
api/internal/slices/slices_test.go
Normal file
131
api/internal/slices/slices_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package slices
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type filterTestCase[T any] struct {
|
||||
name string
|
||||
input []T
|
||||
expected []T
|
||||
predicate func(T) bool
|
||||
}
|
||||
|
||||
func TestFilter(t *testing.T) {
|
||||
|
||||
intTestCases := []filterTestCase[int]{
|
||||
{
|
||||
name: "Filter even numbers",
|
||||
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
expected: []int{2, 4, 6, 8},
|
||||
|
||||
predicate: func(n int) bool {
|
||||
return n%2 == 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Filter odd numbers",
|
||||
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
expected: []int{1, 3, 5, 7, 9},
|
||||
|
||||
predicate: func(n int) bool {
|
||||
return n%2 != 0
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTestCases(t, intTestCases)
|
||||
|
||||
stringTestCases := []filterTestCase[string]{
|
||||
{
|
||||
name: "Filter strings starting with 'A'",
|
||||
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
|
||||
expected: []string{"Apple", "Avocado", "Apricot"},
|
||||
predicate: func(s string) bool {
|
||||
return s[0] == 'A'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Filter strings longer than 5 characters",
|
||||
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
|
||||
expected: []string{"Banana", "Avocado", "Grapes", "Apricot"},
|
||||
predicate: func(s string) bool {
|
||||
return len(s) > 5
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTestCases(t, stringTestCases)
|
||||
|
||||
}
|
||||
|
||||
func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
result := Filter(testCase.input, testCase.predicate)
|
||||
|
||||
is.Equal(len(testCase.expected), len(result))
|
||||
is.ElementsMatch(testCase.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
intTestCases := []struct {
|
||||
name string
|
||||
input []int
|
||||
expected []string
|
||||
mapper func(int) string
|
||||
}{
|
||||
{
|
||||
name: "Map integers to strings",
|
||||
input: []int{1, 2, 3, 4, 5},
|
||||
expected: []string{"1", "2", "3", "4", "5"},
|
||||
mapper: func(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMapTestCases(t, intTestCases)
|
||||
|
||||
stringTestCases := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []int
|
||||
mapper func(string) int
|
||||
}{
|
||||
{
|
||||
name: "Map strings to integers",
|
||||
input: []string{"1", "2", "3", "4", "5"},
|
||||
expected: []int{1, 2, 3, 4, 5},
|
||||
mapper: func(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMapTestCases(t, stringTestCases)
|
||||
}
|
||||
|
||||
func runMapTestCases[T, U any](t *testing.T, testCases []struct {
|
||||
name string
|
||||
input []T
|
||||
expected []U
|
||||
mapper func(T) U
|
||||
}) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
result := Map(testCase.input, testCase.mapper)
|
||||
|
||||
is.Equal(len(testCase.expected), len(result))
|
||||
is.ElementsMatch(testCase.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package testhelpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -37,7 +36,7 @@ type testDatastore struct {
|
||||
pendingActionsService dataservices.PendingActionsService
|
||||
}
|
||||
|
||||
func (d *testDatastore) BackupTo(io.Writer) error { return nil }
|
||||
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
|
||||
func (d *testDatastore) Open() (bool, error) { return false, nil }
|
||||
func (d *testDatastore) Init() error { return nil }
|
||||
func (d *testDatastore) Close() error { return nil }
|
||||
@@ -57,9 +56,11 @@ func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { re
|
||||
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||
return d.fdoProfile
|
||||
}
|
||||
|
||||
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
||||
return d.endpointRelation
|
||||
}
|
||||
|
||||
func (d *testDatastore) HelmUserRepository() dataservices.HelmUserRepositoryService {
|
||||
return d.helmUserRepository
|
||||
}
|
||||
@@ -94,6 +95,7 @@ func (d *testDatastore) IsErrObjectNotFound(e error) bool {
|
||||
func (d *testDatastore) Export(filename string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *testDatastore) Import(filename string) (err error) {
|
||||
return nil
|
||||
}
|
||||
@@ -119,10 +121,12 @@ func (s *stubSettingsService) BucketName() string { return "settings" }
|
||||
func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
|
||||
return s.settings, nil
|
||||
}
|
||||
|
||||
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
|
||||
s.settings = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
d.settings = &stubSettingsService{
|
||||
@@ -162,15 +166,19 @@ func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.j
|
||||
func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
|
||||
return nil
|
||||
}
|
||||
@@ -192,6 +200,7 @@ func (s *stubEndpointRelationService) BucketName() string { return "endpoint_rel
|
||||
func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||
return s.relations, nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) {
|
||||
for _, relation := range s.relations {
|
||||
if relation.EndpointID == ID {
|
||||
@@ -201,9 +210,11 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == ID {
|
||||
@@ -213,6 +224,7 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||
return nil
|
||||
}
|
||||
@@ -307,7 +319,7 @@ func (s *stubEndpointService) GetNextIdentifier() int {
|
||||
}
|
||||
|
||||
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
endpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, e := range s.endpoints {
|
||||
for t := range e.TeamAccessPolicies {
|
||||
|
||||
@@ -54,6 +54,10 @@ func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenDat
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AddTestSecurityCookie adds a security cookie to the request
|
||||
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
||||
r.AddCookie(&http.Cookie{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user