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