Compare commits
1 Commits
fix/releas
...
fix/EE-611
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998eec3093 |
@@ -23,7 +23,7 @@ parserOptions:
|
||||
modules: true
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
no-console: warn
|
||||
no-alert: error
|
||||
no-control-regex: 'off'
|
||||
no-empty: warn
|
||||
@@ -116,9 +116,10 @@ overrides:
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
- 'plugin:jest/recommended'
|
||||
- 'plugin:jest/style'
|
||||
env:
|
||||
'vitest/env': true
|
||||
'jest/globals': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
- files:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -93,7 +93,6 @@ body:
|
||||
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.19.4'
|
||||
- '2.19.3'
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
|
||||
136
.github/workflows/ci.yaml
vendored
136
.github/workflows/ci.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- 'release/*'
|
||||
- '!release/*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'develop'
|
||||
@@ -13,16 +13,11 @@ on:
|
||||
- 'feat/*'
|
||||
- 'fix/*'
|
||||
- 'refactor/*'
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||
GO_VERSION: 1.21.6
|
||||
DOCKER_HUB_REPO: portainerci/portainer
|
||||
NODE_ENV: testing
|
||||
GO_VERSION: 1.21.3
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
@@ -30,72 +25,85 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64, version: "" }
|
||||
- { platform: linux, arch: arm64, version: "" }
|
||||
- { platform: linux, arch: arm, version: "" }
|
||||
- { platform: linux, arch: ppc64le, version: "" }
|
||||
- { platform: linux, arch: s390x, version: "" }
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: arc-runner-set
|
||||
steps:
|
||||
- name: '[preparation] checkout the current branch'
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v3.5.3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: '[preparation] set up golang'
|
||||
uses: actions/setup-go@v5.0.0
|
||||
uses: actions/setup-go@v4.0.1
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
- name: '[preparation] cache paths'
|
||||
id: cache-dir-path
|
||||
run: |
|
||||
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
|
||||
- name: '[preparation] cache go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.cache-dir-path.outputs.go-build-dir }}
|
||||
${{ steps.cache-dir-path.outputs.go-mod-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up node.js'
|
||||
uses: actions/setup-node@v4.0.1
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
cache: ''
|
||||
- name: '[preparation] cache yarn'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up qemu'
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set the container image tag'
|
||||
run: |
|
||||
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
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
|
||||
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
|
||||
- name: '[execution] build linux & windows portainer binaries'
|
||||
run: |
|
||||
export YARN_VERSION=$(yarn --version)
|
||||
export WEBPACK_VERSION=$(yarn list webpack --depth=0 | grep webpack | awk -F@ '{print $2}')
|
||||
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||
|
||||
NODE_ENV="testing"
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
NODE_ENV="production"
|
||||
fi
|
||||
|
||||
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
@@ -107,70 +115,34 @@ jobs:
|
||||
else
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
fi
|
||||
fi
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
build_manifests:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: arc-runner-set
|
||||
needs: [build_images]
|
||||
steps:
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v3.0.0
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker version && docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[execution] build and push manifests'
|
||||
run: |
|
||||
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
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
fi
|
||||
|
||||
14
.github/workflows/lint.yml
vendored
14
.github/workflows/lint.yml
vendored
@@ -11,27 +11,20 @@ on:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
GO_VERSION: 1.21.3
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
@@ -51,5 +44,6 @@ jobs:
|
||||
- name: GolangCI-Lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.55.2
|
||||
version: v1.54.1
|
||||
working-directory: api
|
||||
args: --timeout=10m -c .golangci.yaml
|
||||
|
||||
6
.github/workflows/nightly-security-scan.yml
vendored
6
.github/workflows/nightly-security-scan.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.3
|
||||
|
||||
jobs:
|
||||
client-dependencies:
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
image: portainerci/portainer:develop
|
||||
sarif-file: image-docker-scout.json
|
||||
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: upload Docker Scout image security scan result as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
matrix.js.status == 'failure' ||
|
||||
matrix.go.status == 'failure' ||
|
||||
matrix.image-trivy.status == 'failure' ||
|
||||
matrix.image-docker-scout.status == 'failure'
|
||||
matrix.image-docker-scout.status == 'failure'
|
||||
uses: slackapi/slack-github-action@v1.23.0
|
||||
with:
|
||||
payload: |
|
||||
|
||||
14
.github/workflows/pr-security.yml
vendored
14
.github/workflows/pr-security.yml
vendored
@@ -14,7 +14,7 @@ on:
|
||||
- '.github/workflows/pr-security.yml'
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.3
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
@@ -23,8 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
github.event.review.body == '/scan'
|
||||
outputs:
|
||||
jsdiff: ${{ steps.set-diff-matrix.outputs.js_diff_result }}
|
||||
steps:
|
||||
@@ -78,8 +77,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
github.event.review.body == '/scan'
|
||||
outputs:
|
||||
godiff: ${{ steps.set-diff-matrix.outputs.go_diff_result }}
|
||||
steps:
|
||||
@@ -141,8 +139,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
github.event.review.body == '/scan'
|
||||
outputs:
|
||||
imagediff-trivy: ${{ steps.set-diff-trivy-matrix.outputs.image_diff_trivy_result }}
|
||||
imagediff-docker-scout: ${{ steps.set-diff-docker-scout-matrix.outputs.image_diff_docker_scout_result }}
|
||||
@@ -271,8 +268,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
github.event.review.body == '/scan'
|
||||
strategy:
|
||||
matrix:
|
||||
jsdiff: ${{fromJson(needs.client-dependencies.outputs.jsdiff)}}
|
||||
|
||||
20
.github/workflows/test.yaml
vendored
20
.github/workflows/test.yaml
vendored
@@ -1,22 +1,14 @@
|
||||
name: Test
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
on: push
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
env:
|
||||
GO_VERSION: 1.21.3
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
test-client:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -27,7 +19,7 @@ jobs:
|
||||
- run: yarn --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
|
||||
run: make test-client ARGS="--maxWorkers=2"
|
||||
test-server:
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -37,8 +29,6 @@ jobs:
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
|
||||
8
.github/workflows/validate-openapi-spec.yaml
vendored
8
.github/workflows/validate-openapi-spec.yaml
vendored
@@ -6,20 +6,14 @@ on:
|
||||
- master
|
||||
- develop
|
||||
- 'release/*'
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.3
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
openapi-spec:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { StorybookConfig } from '@storybook/react-webpack5';
|
||||
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
||||
import { Configuration } from 'webpack';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||
addons: [
|
||||
@@ -88,6 +87,9 @@ const config: StorybookConfig = {
|
||||
name: '@storybook/react-webpack5',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
|
||||
import { handlers } from '@/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
console.log(method, url);
|
||||
if (url.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
// Initialize MSW
|
||||
initMSW({
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
|
||||
This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories.
|
||||
|
||||
If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses
|
||||
`);
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
handlers
|
||||
);
|
||||
});
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
@@ -47,6 +44,5 @@ export const decorators = [
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
mswDecorator,
|
||||
];
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
@@ -2,22 +2,22 @@
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (2.0.11).
|
||||
* Mock Service Worker (0.36.3).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
|
||||
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929';
|
||||
const bypassHeaderName = 'x-msw-bypass';
|
||||
const activeClientIds = new Set();
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting();
|
||||
return self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
self.addEventListener('activate', async function (event) {
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
@@ -33,9 +33,7 @@ self.addEventListener('message', async function (event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
});
|
||||
const allClients = await self.clients.matchAll();
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
@@ -85,8 +83,165 @@ self.addEventListener('message', async function (event) {
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the "main" client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (client.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll();
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible';
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
(async function () {
|
||||
const clonedResponse = response.clone();
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body: clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: serializeHeaders(clonedResponse.headers),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
const requestClone = request.clone();
|
||||
const getOriginalResponse = () => fetch(requestClone);
|
||||
|
||||
// Bypass mocking when the request client is not active.
|
||||
if (!client) {
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return await getOriginalResponse();
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header
|
||||
if (requestClone.headers.get(bypassHeaderName) === 'true') {
|
||||
const cleanRequestHeaders = serializeHeaders(requestClone.headers);
|
||||
|
||||
// Remove the bypass header to comply with the CORS preflight check.
|
||||
delete cleanRequestHeaders[bypassHeaderName];
|
||||
|
||||
const originalRequest = new Request(requestClone, {
|
||||
headers: new Headers(cleanRequestHeaders),
|
||||
});
|
||||
|
||||
return fetch(originalRequest);
|
||||
}
|
||||
|
||||
// Send the request to the client-side MSW.
|
||||
const reqHeaders = serializeHeaders(request.headers);
|
||||
const body = await request.text();
|
||||
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: reqHeaders,
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body,
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
});
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_SUCCESS': {
|
||||
return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.payload;
|
||||
const networkError = new Error(message);
|
||||
networkError.name = name;
|
||||
|
||||
// Rejecting a request Promise emulates a network error.
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
case 'INTERNAL_ERROR': {
|
||||
const parsedBody = JSON.parse(clientMessage.payload.body);
|
||||
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Uncaught exception in the request handler for "%s %s":
|
||||
|
||||
${parsedBody.location}
|
||||
|
||||
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
|
||||
`,
|
||||
request.method,
|
||||
request.url
|
||||
);
|
||||
|
||||
return respondWithMock(clientMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return getOriginalResponse();
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event;
|
||||
const accept = request.headers.get('accept') || '';
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
@@ -106,149 +261,36 @@ self.addEventListener('fetch', function (event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID();
|
||||
event.respondWith(handleRequest(event, requestId));
|
||||
const requestId = uuidv4();
|
||||
|
||||
return event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn('[MSW] Successfully emulated a network error for the "%s %s" request.', request.method, request.url);
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
(async function () {
|
||||
const responseClone = response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
},
|
||||
},
|
||||
[responseClone.body]
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
function serializeHeaders(headers) {
|
||||
const reqHeaders = {};
|
||||
headers.forEach((value, name) => {
|
||||
reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value;
|
||||
});
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible';
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id);
|
||||
});
|
||||
return reqHeaders;
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone();
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries());
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention'];
|
||||
|
||||
return fetch(requestClone, { headers });
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
const mswIntention = request.headers.get('x-msw-intention');
|
||||
if (['bypass', 'passthrough'].includes(mswIntention)) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer();
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
},
|
||||
[requestBuffer]
|
||||
);
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough();
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
|
||||
@@ -260,25 +302,27 @@ function sendToClient(client, message, transferrables = []) {
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
|
||||
client.postMessage(JSON.stringify(message), [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error();
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response);
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
function delayPromise(cb, duration) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(cb()), duration);
|
||||
});
|
||||
}
|
||||
|
||||
function respondWithMock(clientMessage) {
|
||||
return new Response(clientMessage.payload.body, {
|
||||
...clientMessage.payload,
|
||||
headers: clientMessage.payload.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function uuidv4() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
|
||||
return mockedResponse;
|
||||
}
|
||||
|
||||
12
Makefile
12
Makefile
@@ -7,9 +7,9 @@ ARCH=$(shell go env GOARCH)
|
||||
# build target, can be one of "production", "testing", "development"
|
||||
ENV=development
|
||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||
TAG=local
|
||||
TAG=latest
|
||||
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.8.11
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@latest
|
||||
|
||||
# Don't change anything below this line unless you know what you're doing
|
||||
@@ -68,7 +68,7 @@ test-client: ## Run client tests
|
||||
yarn test $(ARGS)
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
||||
cd api && $(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
||||
|
||||
##@ Dev
|
||||
.PHONY: dev dev-client dev-server
|
||||
@@ -92,7 +92,7 @@ format-client: ## Format client code
|
||||
yarn format
|
||||
|
||||
format-server: ## Format server code
|
||||
go fmt ./...
|
||||
cd api && go fmt ./...
|
||||
|
||||
##@ Lint
|
||||
.PHONY: lint lint-client lint-server
|
||||
@@ -102,7 +102,7 @@ lint-client: ## Lint client code
|
||||
yarn lint
|
||||
|
||||
lint-server: ## Lint server code
|
||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||
cd api && go vet ./...
|
||||
|
||||
|
||||
##@ Extension
|
||||
@@ -114,7 +114,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
|
||||
##@ Docs
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 --markdownFiles ./
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
|
||||
@@ -4,13 +4,10 @@ linters:
|
||||
|
||||
# Enable these for now
|
||||
enable:
|
||||
- unused
|
||||
- depguard
|
||||
- gosimple
|
||||
- govet
|
||||
- errorlint
|
||||
- exportloopref
|
||||
|
||||
linters-settings:
|
||||
depguard:
|
||||
rules:
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
|
||||
// APIKeyService represents a service for managing API keys.
|
||||
type APIKeyService interface {
|
||||
HashRaw(rawKey string) string
|
||||
HashRaw(rawKey string) []byte
|
||||
GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error)
|
||||
GetAPIKey(apiKeyID portainer.APIKeyID) (*portainer.APIKey, error)
|
||||
GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey, error)
|
||||
GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error)
|
||||
GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error)
|
||||
UpdateAPIKey(apiKey *portainer.APIKey) error
|
||||
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
|
||||
InvalidateUserKeyCache(userId portainer.UserID) bool
|
||||
|
||||
@@ -33,8 +33,8 @@ func NewAPIKeyCache(cacheSize int) *apiKeyCache {
|
||||
// Get returns the user/key associated to an api-key's digest
|
||||
// This is required because HTTP requests will contain the digest of the API key in header,
|
||||
// the digest value must be mapped to a portainer user.
|
||||
func (c *apiKeyCache) Get(digest string) (portainer.User, portainer.APIKey, bool) {
|
||||
val, ok := c.cache.Get(digest)
|
||||
func (c *apiKeyCache) Get(digest []byte) (portainer.User, portainer.APIKey, bool) {
|
||||
val, ok := c.cache.Get(string(digest))
|
||||
if !ok {
|
||||
return portainer.User{}, portainer.APIKey{}, false
|
||||
}
|
||||
@@ -44,23 +44,23 @@ func (c *apiKeyCache) Get(digest string) (portainer.User, portainer.APIKey, bool
|
||||
}
|
||||
|
||||
// Set persists a user/key entry to the cache
|
||||
func (c *apiKeyCache) Set(digest string, user portainer.User, apiKey portainer.APIKey) {
|
||||
c.cache.Add(digest, entry{
|
||||
func (c *apiKeyCache) Set(digest []byte, user portainer.User, apiKey portainer.APIKey) {
|
||||
c.cache.Add(string(digest), entry{
|
||||
user: user,
|
||||
apiKey: apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete evicts a digest's user/key entry key from the cache
|
||||
func (c *apiKeyCache) Delete(digest string) {
|
||||
c.cache.Remove(digest)
|
||||
func (c *apiKeyCache) Delete(digest []byte) {
|
||||
c.cache.Remove(string(digest))
|
||||
}
|
||||
|
||||
// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache
|
||||
func (c *apiKeyCache) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||
present := false
|
||||
for _, k := range c.cache.Keys() {
|
||||
user, _, _ := c.Get(k.(string))
|
||||
user, _, _ := c.Get([]byte(k.(string)))
|
||||
if user.ID == userId {
|
||||
present = c.cache.Remove(k)
|
||||
}
|
||||
|
||||
@@ -17,19 +17,19 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
||||
keyCache.cache.Add(string(""), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||
|
||||
tests := []struct {
|
||||
digest string
|
||||
digest []byte
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
digest: "foo",
|
||||
digest: []byte("foo"),
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
digest: "",
|
||||
digest: []byte(""),
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
digest: "bar",
|
||||
digest: []byte("bar"),
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
@@ -48,11 +48,11 @@ func Test_apiKeyCacheSet(t *testing.T) {
|
||||
keyCache := NewAPIKeyCache(10)
|
||||
|
||||
// pre-populate cache
|
||||
keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{})
|
||||
keyCache.Set("foo", portainer.User{ID: 1}, portainer.APIKey{})
|
||||
keyCache.Set([]byte("bar"), portainer.User{ID: 2}, portainer.APIKey{})
|
||||
keyCache.Set([]byte("foo"), portainer.User{ID: 1}, portainer.APIKey{})
|
||||
|
||||
// overwrite existing entry
|
||||
keyCache.Set("foo", portainer.User{ID: 3}, portainer.APIKey{})
|
||||
keyCache.Set([]byte("foo"), portainer.User{ID: 3}, portainer.APIKey{})
|
||||
|
||||
val, ok := keyCache.cache.Get(string("bar"))
|
||||
is.True(ok)
|
||||
@@ -74,14 +74,14 @@ func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
|
||||
t.Run("Delete an existing entry", func(t *testing.T) {
|
||||
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.Delete("foo")
|
||||
keyCache.Delete([]byte("foo"))
|
||||
|
||||
_, ok := keyCache.cache.Get(string("foo"))
|
||||
is.False(ok)
|
||||
})
|
||||
|
||||
t.Run("Delete a non-existing entry", func(t *testing.T) {
|
||||
nonPanicFunc := func() { keyCache.Delete("non-existent-key") }
|
||||
nonPanicFunc := func() { keyCache.Delete([]byte("non-existent-key")) }
|
||||
is.NotPanics(nonPanicFunc)
|
||||
})
|
||||
}
|
||||
@@ -131,16 +131,16 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
keyCache := NewAPIKeyCache(test.cacheLen)
|
||||
|
||||
for _, key := range test.key {
|
||||
keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{})
|
||||
keyCache.Set([]byte(key), portainer.User{ID: 1}, portainer.APIKey{})
|
||||
}
|
||||
|
||||
for _, key := range test.foundKeys {
|
||||
_, _, found := keyCache.Get(key)
|
||||
_, _, found := keyCache.Get([]byte(key))
|
||||
is.True(found, "Key %s not found", key)
|
||||
}
|
||||
|
||||
for _, key := range test.evictedKeys {
|
||||
_, _, found := keyCache.Get(key)
|
||||
_, _, found := keyCache.Get([]byte(key))
|
||||
is.False(found, "key %s should have been evicted", key)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,9 +32,9 @@ func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userReposi
|
||||
}
|
||||
|
||||
// HashRaw computes a hash digest of provided raw API key.
|
||||
func (a *apiKeyService) HashRaw(rawKey string) string {
|
||||
func (a *apiKeyService) HashRaw(rawKey string) []byte {
|
||||
hashDigest := sha256.Sum256([]byte(rawKey))
|
||||
return base64.StdEncoding.EncodeToString(hashDigest[:])
|
||||
return hashDigest[:]
|
||||
}
|
||||
|
||||
// GenerateApiKey generates a raw API key for a user (for one-time display).
|
||||
@@ -77,7 +77,7 @@ func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey,
|
||||
|
||||
// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest.
|
||||
// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed.
|
||||
func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) {
|
||||
func (a *apiKeyService) GetDigestUserAndKey(digest []byte) (portainer.User, portainer.APIKey, error) {
|
||||
// get api key from cache if possible
|
||||
cachedUser, cachedKey, ok := a.cache.Get(digest)
|
||||
if ok {
|
||||
|
||||
@@ -2,7 +2,6 @@ package apikey
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -69,7 +68,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
is.Equal(apiKey.Digest, base64.StdEncoding.EncodeToString(generatedDigest[:]))
|
||||
is.Equal(apiKey.Digest, generatedDigest[:])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,18 @@ func TarGzDir(absolutePath string) (string, error) {
|
||||
}
|
||||
|
||||
func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info os.FileInfo) error {
|
||||
header, err := tar.FileInfoHeader(info, info.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header.Name = pathInArchive // use relative paths in archive
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
@@ -56,26 +68,6 @@ func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(stat, stat.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = pathInArchive // use relative paths in archive
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stat.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
@@ -106,7 +98,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
outFile, err := os.Create(p)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const rwxr__r__ os.FileMode = 0o744
|
||||
const rwxr__r__ os.FileMode = 0744
|
||||
|
||||
var filesToBackup = []string{
|
||||
"certs",
|
||||
@@ -82,8 +82,14 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
|
||||
return err
|
||||
backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = datastore.BackupTo(backupWriter); err != nil {
|
||||
return err
|
||||
}
|
||||
return backupWriter.Close()
|
||||
}
|
||||
|
||||
func encrypt(path string, passphrase string) (string, error) {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package build
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Variables to be set during the build time
|
||||
var BuildNumber string
|
||||
var ImageTag string
|
||||
var NodejsVersion string
|
||||
var YarnVersion string
|
||||
var WebpackVersion string
|
||||
var GoVersion string = runtime.Version()
|
||||
var GitCommit string
|
||||
var GoVersion string
|
||||
|
||||
@@ -21,7 +21,6 @@ const (
|
||||
tunnelCleanupInterval = 10 * time.Second
|
||||
requiredTimeout = 15 * time.Second
|
||||
activeTimeout = 4*time.Minute + 30*time.Second
|
||||
pingTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// Service represents a service to manage the state of multiple reverse tunnels.
|
||||
@@ -60,18 +59,14 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: pingTimeout,
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
endpointID := portainer.EndpointID(1)
|
||||
|
||||
s := NewService(nil, nil, nil)
|
||||
|
||||
defer func() {
|
||||
require.Nil(t, recover())
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(pingTimeout + 1*time.Second)
|
||||
})
|
||||
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, http.Serve(ln, mux))
|
||||
}()
|
||||
|
||||
s.getTunnelDetails(endpointID)
|
||||
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
require.Error(t, s.pingAgent(endpointID))
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// Confirm starts a rollback db cli application
|
||||
func Confirm(message string) (bool, error) {
|
||||
fmt.Printf("%s [y/N] ", message)
|
||||
fmt.Printf("%s [y/N]", message)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
@@ -629,6 +631,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
configureLogger()
|
||||
setLoggingMode("PRETTY")
|
||||
|
||||
|
||||
@@ -144,8 +144,6 @@ func (connection *DbConnection) Open() error {
|
||||
// Close closes the BoltDB database.
|
||||
// Safe to being called multiple times.
|
||||
func (connection *DbConnection) Close() error {
|
||||
log.Info().Msg("closing PortainerDB")
|
||||
|
||||
if connection.DB != nil {
|
||||
return connection.DB.Close()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apikeyrepository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -36,7 +37,7 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
|
||||
// GetAPIKeysByUserID returns a slice containing all the APIKeys a user has access to.
|
||||
func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error) {
|
||||
result := make([]portainer.APIKey, 0)
|
||||
var result = make([]portainer.APIKey, 0)
|
||||
|
||||
err := service.Connection.GetAll(
|
||||
BucketName,
|
||||
@@ -60,7 +61,7 @@ func (service *Service) GetAPIKeysByUserID(userID portainer.UserID) ([]portainer
|
||||
|
||||
// GetAPIKeyByDigest returns the API key for the associated digest.
|
||||
// Note: there is a 1-to-1 mapping of api-key and digest
|
||||
func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, error) {
|
||||
func (service *Service) GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, error) {
|
||||
var k *portainer.APIKey
|
||||
stop := fmt.Errorf("ok")
|
||||
err := service.Connection.GetAll(
|
||||
@@ -72,7 +73,7 @@ func (service *Service) GetAPIKeyByDigest(digest string) (*portainer.APIKey, err
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to APIKey object")
|
||||
return nil, fmt.Errorf("failed to convert to APIKey object: %s", obj)
|
||||
}
|
||||
if key.Digest == digest {
|
||||
if bytes.Equal(key.Digest, digest) {
|
||||
k = key
|
||||
return nil, stop
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dataservices
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
)
|
||||
@@ -44,7 +46,7 @@ type (
|
||||
MigrateData() error
|
||||
Rollback(force bool) error
|
||||
CheckCurrentEdition() error
|
||||
Backup(path string) (string, error)
|
||||
BackupTo(w io.Writer) error
|
||||
Export(filename string) (err error)
|
||||
|
||||
DataStoreTx
|
||||
@@ -150,7 +152,7 @@ type (
|
||||
APIKeyRepository interface {
|
||||
BaseCRUD[portainer.APIKey, portainer.APIKeyID]
|
||||
GetAPIKeysByUserID(userID portainer.UserID) ([]portainer.APIKey, error)
|
||||
GetAPIKeyByDigest(digest string) (*portainer.APIKey, error)
|
||||
GetAPIKeyByDigest(digest []byte) (*portainer.APIKey, error)
|
||||
}
|
||||
|
||||
// SettingsService represents a service for managing application settings
|
||||
|
||||
@@ -4,89 +4,184 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Backup takes an optional output path and creates a backup of the database.
|
||||
// The database connection is stopped before running the backup to avoid any
|
||||
// corruption and if a path is not given a default is used.
|
||||
// The path or an error are returned.
|
||||
func (store *Store) Backup(path string) (string, error) {
|
||||
if err := store.createBackupPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
backupFilename := store.backupFilename()
|
||||
if path != "" {
|
||||
backupFilename = path
|
||||
}
|
||||
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
|
||||
|
||||
// Close the store before backing up
|
||||
err := store.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to close store before backup: %w", err)
|
||||
}
|
||||
|
||||
err = store.fileService.Copy(store.connection.GetDatabaseFilePath(), backupFilename, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create backup file: %w", err)
|
||||
}
|
||||
|
||||
// reopen the store
|
||||
_, err = store.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reopen store after backup: %w", err)
|
||||
}
|
||||
|
||||
return backupFilename, nil
|
||||
var backupDefaults = struct {
|
||||
backupDir string
|
||||
commonDir string
|
||||
}{
|
||||
"backups",
|
||||
"common",
|
||||
}
|
||||
|
||||
func (store *Store) Restore() error {
|
||||
backupFilename := store.backupFilename()
|
||||
return store.RestoreFromFile(backupFilename)
|
||||
}
|
||||
//
|
||||
// Backup Helpers
|
||||
//
|
||||
|
||||
func (store *Store) RestoreFromFile(backupFilename string) error {
|
||||
store.Close()
|
||||
if err := store.fileService.Copy(backupFilename, store.connection.GetDatabaseFilePath(), true); err != nil {
|
||||
return fmt.Errorf("unable to restore backup file %q. err: %w", backupFilename, err)
|
||||
}
|
||||
|
||||
log.Info().Str("from", backupFilename).Str("to", store.connection.GetDatabaseFilePath()).Msgf("database restored")
|
||||
|
||||
_, err := store.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine version of restored portainer backup file: %w", err)
|
||||
}
|
||||
|
||||
// determine the db version
|
||||
version, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine restored database version. err: %w", err)
|
||||
}
|
||||
|
||||
editionLabel := portainer.SoftwareEdition(version.Edition).GetEditionLabel()
|
||||
log.Info().Msgf("Restored database version: Portainer %s %s", editionLabel, version.SchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) createBackupPath() error {
|
||||
backupDir := path.Join(store.connection.GetStorePath(), "backups")
|
||||
if exists, _ := store.fileService.FileExists(backupDir); !exists {
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return fmt.Errorf("unable to create backup folder: %w", err)
|
||||
// createBackupFolders create initial folders for backups
|
||||
func (store *Store) createBackupFolders() {
|
||||
// create common dir
|
||||
commonDir := store.commonBackupDir()
|
||||
if exists, _ := store.fileService.FileExists(commonDir); !exists {
|
||||
if err := os.MkdirAll(commonDir, 0700); err != nil {
|
||||
log.Error().Err(err).Msg("error while creating common backup folder")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) backupFilename() string {
|
||||
return path.Join(store.connection.GetStorePath(), "backups", store.connection.GetDatabaseFileName()+".bak")
|
||||
}
|
||||
|
||||
func (store *Store) databasePath() string {
|
||||
return store.connection.GetDatabaseFilePath()
|
||||
}
|
||||
|
||||
func (store *Store) commonBackupDir() string {
|
||||
return path.Join(store.connection.GetStorePath(), backupDefaults.backupDir, backupDefaults.commonDir)
|
||||
}
|
||||
|
||||
func (store *Store) copyDBFile(from string, to string) error {
|
||||
log.Info().Str("from", from).Str("to", to).Msg("copying DB file")
|
||||
|
||||
err := store.fileService.Copy(from, to, true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BackupOptions provide a helper to inject backup options
|
||||
type BackupOptions struct {
|
||||
Version string
|
||||
BackupDir string
|
||||
BackupFileName string
|
||||
BackupPath string
|
||||
}
|
||||
|
||||
// getBackupRestoreOptions returns options to store db at common backup dir location; used by:
|
||||
// - db backup prior to version upgrade
|
||||
// - db rollback
|
||||
func getBackupRestoreOptions(backupDir string) *BackupOptions {
|
||||
return &BackupOptions{
|
||||
BackupDir: backupDir,
|
||||
BackupFileName: beforePortainerVersionUpgradeBackup,
|
||||
}
|
||||
}
|
||||
|
||||
// Backup current database with default options
|
||||
func (store *Store) Backup(version *models.Version) (string, error) {
|
||||
if version == nil {
|
||||
return store.backupWithOptions(nil)
|
||||
}
|
||||
|
||||
backupOptions := getBackupRestoreOptions(store.commonBackupDir())
|
||||
backupOptions.Version = version.SchemaVersion
|
||||
return store.backupWithOptions(backupOptions)
|
||||
}
|
||||
|
||||
func (store *Store) setDefaultBackupOptions(options *BackupOptions) *BackupOptions {
|
||||
if options == nil {
|
||||
options = &BackupOptions{}
|
||||
}
|
||||
if options.Version == "" {
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
options.Version = ""
|
||||
}
|
||||
options.Version = v.SchemaVersion
|
||||
}
|
||||
if options.BackupDir == "" {
|
||||
options.BackupDir = store.commonBackupDir()
|
||||
}
|
||||
if options.BackupFileName == "" {
|
||||
options.BackupFileName = fmt.Sprintf("%s.%s.%s", store.connection.GetDatabaseFileName(), options.Version, time.Now().Format("20060102150405"))
|
||||
}
|
||||
if options.BackupPath == "" {
|
||||
options.BackupPath = path.Join(options.BackupDir, options.BackupFileName)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// BackupWithOptions backup current database with options
|
||||
func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
|
||||
log.Info().Msg("creating DB backup")
|
||||
|
||||
store.createBackupFolders()
|
||||
|
||||
options = store.setDefaultBackupOptions(options)
|
||||
dbPath := store.databasePath()
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error closing datastore before creating backup: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
|
||||
return options.BackupPath, err
|
||||
}
|
||||
|
||||
if _, err := store.Open(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error opening datastore after creating backup: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return options.BackupPath, nil
|
||||
}
|
||||
|
||||
// RestoreWithOptions previously saved backup for the current Edition with options
|
||||
// Restore strategies:
|
||||
// - default: restore latest from current edition
|
||||
// - restore a specific
|
||||
func (store *Store) restoreWithOptions(options *BackupOptions) error {
|
||||
options = store.setDefaultBackupOptions(options)
|
||||
|
||||
// Check if backup file exist before restoring
|
||||
_, err := os.Stat(options.BackupPath)
|
||||
if os.IsNotExist(err) {
|
||||
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to restore does not exist")
|
||||
return err
|
||||
}
|
||||
|
||||
err = store.Close()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error while closing store before restore")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Msg("restoring DB backup")
|
||||
err = store.copyDBFile(options.BackupPath, store.databasePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = store.Open()
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveWithOptions removes backup database based on supplied options
|
||||
func (store *Store) removeWithOptions(options *BackupOptions) error {
|
||||
log.Info().Msg("removing DB backup")
|
||||
|
||||
options = store.setDefaultBackupOptions(options)
|
||||
_, err := os.Stat(options.BackupPath)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
log.Error().Str("path", options.BackupPath).Err(err).Msg("backup file to remove does not exist")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("path", options.BackupPath).Msg("removing DB file")
|
||||
err = os.Remove(options.BackupPath)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,79 +2,106 @@ package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func TestCreateBackupFolders(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
|
||||
connection := store.GetConnection()
|
||||
backupPath := path.Join(connection.GetStorePath(), backupDefaults.backupDir)
|
||||
|
||||
if isFileExist(backupPath) {
|
||||
t.Error("Expect backups folder to not exist")
|
||||
}
|
||||
|
||||
store.createBackupFolders()
|
||||
if !isFileExist(backupPath) {
|
||||
t.Error("Expect backups folder to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCreation(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
if store == nil {
|
||||
t.Fatal("Expect to create a store")
|
||||
t.Error("Expect to create a store")
|
||||
}
|
||||
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
if portainer.SoftwareEdition(v.Edition) != portainer.PortainerCE {
|
||||
if store.CheckCurrentEdition() != nil {
|
||||
t.Error("Expect to get CE Edition")
|
||||
}
|
||||
|
||||
if v.SchemaVersion != portainer.APIVersion {
|
||||
t.Error("Expect to get APIVersion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
backupFileName := store.backupFilename()
|
||||
t.Run(fmt.Sprintf("Backup should create %s", backupFileName), func(t *testing.T) {
|
||||
connection := store.GetConnection()
|
||||
|
||||
t.Run("Backup should create default db backup", func(t *testing.T) {
|
||||
v := models.Version{
|
||||
Edition: int(portainer.PortainerCE),
|
||||
SchemaVersion: portainer.APIVersion,
|
||||
}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
store.Backup("")
|
||||
store.backupWithOptions(nil)
|
||||
|
||||
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", fmt.Sprintf("portainer.edb.%s.*", portainer.APIVersion))
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BackupWithOption should create a name specific backup at common path", func(t *testing.T) {
|
||||
store.backupWithOptions(&BackupOptions{
|
||||
BackupFileName: beforePortainerVersionUpgradeBackup,
|
||||
BackupDir: store.commonBackupDir(),
|
||||
})
|
||||
backupFileName := path.Join(connection.GetStorePath(), "backups", "common", beforePortainerVersionUpgradeBackup)
|
||||
if !isFileExist(backupFileName) {
|
||||
t.Errorf("Expect backup file to be created %s", backupFileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRestore(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
func TestRemoveWithOptions(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
|
||||
t.Run("Basic Restore", func(t *testing.T) {
|
||||
// override and set initial db version and edition
|
||||
updateEdition(store, portainer.PortainerCE)
|
||||
updateVersion(store, "2.4")
|
||||
t.Run("successfully removes file if existent", func(t *testing.T) {
|
||||
store.createBackupFolders()
|
||||
options := &BackupOptions{
|
||||
BackupDir: store.commonBackupDir(),
|
||||
BackupFileName: "test.txt",
|
||||
}
|
||||
|
||||
store.Backup("")
|
||||
updateVersion(store, "2.16")
|
||||
testVersion(store, "2.16", t)
|
||||
store.Restore()
|
||||
filePath := path.Join(options.BackupDir, options.BackupFileName)
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("file should be created; err=%s", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// check if the restore is successful and the version is correct
|
||||
testVersion(store, "2.4", t)
|
||||
err = store.removeWithOptions(options)
|
||||
if err != nil {
|
||||
t.Errorf("RemoveWithOptions should successfully remove file; err=%v", err)
|
||||
}
|
||||
|
||||
if isFileExist(f.Name()) {
|
||||
t.Errorf("RemoveWithOptions should successfully remove file; file=%s", f.Name())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Basic Restore After Multiple Backups", func(t *testing.T) {
|
||||
// override and set initial db version and edition
|
||||
updateEdition(store, portainer.PortainerCE)
|
||||
updateVersion(store, "2.4")
|
||||
store.Backup("")
|
||||
updateVersion(store, "2.14")
|
||||
updateVersion(store, "2.16")
|
||||
testVersion(store, "2.16", t)
|
||||
store.Restore()
|
||||
t.Run("fails to removes file if non-existent", func(t *testing.T) {
|
||||
options := &BackupOptions{
|
||||
BackupDir: store.commonBackupDir(),
|
||||
BackupFileName: "test.txt",
|
||||
}
|
||||
|
||||
// check if the restore is successful and the version is correct
|
||||
testVersion(store, "2.4", t)
|
||||
err := store.removeWithOptions(options)
|
||||
if err == nil {
|
||||
t.Error("RemoveWithOptions should fail for non-existent file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,14 +31,8 @@ func (store *Store) Open() (newStore bool, err error) {
|
||||
}
|
||||
|
||||
if encryptionReq {
|
||||
backupFilename, err := store.Backup("")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
|
||||
}
|
||||
|
||||
err = store.encryptDB()
|
||||
if err != nil {
|
||||
store.RestoreFromFile(backupFilename) // restore from backup if encryption fails
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// isFileExist is helper function to check for file existence
|
||||
func isFileExist(path string) bool {
|
||||
matches, err := filepath.Glob(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(matches) > 0
|
||||
}
|
||||
|
||||
func updateVersion(store *Store, v string) {
|
||||
version, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
version.SchemaVersion = v
|
||||
|
||||
err = store.VersionService.UpdateVersion(version)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
}
|
||||
|
||||
func updateEdition(store *Store, edition portainer.SoftwareEdition) {
|
||||
version, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
version.Edition = int(edition)
|
||||
|
||||
err = store.VersionService.UpdateVersion(version)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
}
|
||||
|
||||
// testVersion is a helper which tests current store version against wanted version
|
||||
func testVersion(store *Store, versionWant string, t *testing.T) {
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
if v.SchemaVersion != versionWant {
|
||||
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const beforePortainerVersionUpgradeBackup = "portainer.db.bak"
|
||||
|
||||
func (store *Store) MigrateData() error {
|
||||
updating, err := store.VersionService.IsUpdating()
|
||||
if err != nil {
|
||||
@@ -40,7 +42,7 @@ func (store *Store) MigrateData() error {
|
||||
}
|
||||
|
||||
// before we alter anything in the DB, create a backup
|
||||
_, err = store.Backup("")
|
||||
backupPath, err := store.Backup(version)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "while backing up database")
|
||||
}
|
||||
@@ -50,9 +52,9 @@ func (store *Store) MigrateData() error {
|
||||
err = errors.Wrap(err, "failed to migrate database")
|
||||
|
||||
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
|
||||
restoreErr := store.Restore()
|
||||
if restoreErr != nil {
|
||||
return errors.Wrap(restoreErr, "failed to restore database")
|
||||
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
|
||||
if restorErr != nil {
|
||||
return errors.Wrap(restorErr, "failed to restore database")
|
||||
}
|
||||
|
||||
log.Info().Msg("database restored to previous version")
|
||||
@@ -131,6 +133,7 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
|
||||
|
||||
// Rollback to a pre-upgrade backup copy/snapshot of portainer.db
|
||||
func (store *Store) connectionRollback(force bool) error {
|
||||
|
||||
if !force {
|
||||
confirmed, err := cli.Confirm("Are you sure you want to rollback your database to the previous backup?")
|
||||
if err != nil || !confirmed {
|
||||
@@ -138,7 +141,9 @@ func (store *Store) connectionRollback(force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
err := store.Restore()
|
||||
options := getBackupRestoreOptions(store.commonBackupDir())
|
||||
|
||||
err := store.restoreWithOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,25 +2,35 @@ package datastore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
// testVersion is a helper which tests current store version against wanted version
|
||||
func testVersion(store *Store, versionWant string, t *testing.T) {
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
t.Errorf("Expect store version to be %s but was %s with error: %s", versionWant, v.SchemaVersion, err)
|
||||
}
|
||||
if v.SchemaVersion != versionWant {
|
||||
t.Errorf("Expect store version to be %s but was %s", versionWant, v.SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateData(t *testing.T) {
|
||||
tests := []struct {
|
||||
snapshotTests := []struct {
|
||||
testName string
|
||||
srcPath string
|
||||
wantPath string
|
||||
@@ -33,7 +43,7 @@ func TestMigrateData(t *testing.T) {
|
||||
overrideInstanceId: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
for _, test := range snapshotTests {
|
||||
t.Run(test.testName, func(t *testing.T) {
|
||||
err := migrateDBTestHelper(t, test.srcPath, test.wantPath, test.overrideInstanceId)
|
||||
if err != nil {
|
||||
@@ -48,6 +58,7 @@ func TestMigrateData(t *testing.T) {
|
||||
|
||||
t.Run("MigrateData for New Store & Re-Open Check", func(t *testing.T) {
|
||||
newStore, store := MustNewTestStore(t, true, false)
|
||||
|
||||
if !newStore {
|
||||
t.Error("Expect a new DB")
|
||||
}
|
||||
@@ -61,14 +72,75 @@ func TestMigrateData(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: "1.0", Edition: int(portainer.PortainerCE)})
|
||||
tests := []struct {
|
||||
version string
|
||||
expectedVersion string
|
||||
}{
|
||||
{version: "1.24.1", expectedVersion: portainer.APIVersion},
|
||||
{version: "2.0.0", expectedVersion: portainer.APIVersion},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
|
||||
// Setup data
|
||||
v := models.Version{SchemaVersion: tc.version, Edition: int(portainer.PortainerCE)}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
// Required roles by migrations 22.2
|
||||
store.RoleService.Create(&portainer.Role{ID: 1})
|
||||
store.RoleService.Create(&portainer.Role{ID: 2})
|
||||
store.RoleService.Create(&portainer.Role{ID: 3})
|
||||
store.RoleService.Create(&portainer.Role{ID: 4})
|
||||
|
||||
t.Run(fmt.Sprintf("MigrateData for version %s", tc.version), func(t *testing.T) {
|
||||
store.MigrateData()
|
||||
testVersion(store, tc.expectedVersion, t)
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("Restoring DB after migrateData for version %s", tc.version), func(t *testing.T) {
|
||||
store.Rollback(true)
|
||||
store.Open()
|
||||
testVersion(store, tc.version, t)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Error in MigrateData should restore backup before MigrateData", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, false, false)
|
||||
|
||||
v := models.Version{SchemaVersion: "1.24.1", Edition: int(portainer.PortainerCE)}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
store.MigrateData()
|
||||
|
||||
backupfilename := store.backupFilename()
|
||||
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
|
||||
t.Errorf("Expect backup file to be created %s", backupfilename)
|
||||
testVersion(store, v.SchemaVersion, t)
|
||||
})
|
||||
|
||||
t.Run("MigrateData should create backup file upon update", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, false, false)
|
||||
|
||||
v := models.Version{SchemaVersion: "0.0.0", Edition: int(portainer.PortainerCE)}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
store.MigrateData()
|
||||
|
||||
options := store.setDefaultBackupOptions(getBackupRestoreOptions(store.commonBackupDir()))
|
||||
|
||||
if !isFileExist(options.BackupPath) {
|
||||
t.Errorf("Backup file should exist; file=%s", options.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, false, false)
|
||||
|
||||
store.VersionService.StoreIsUpdating(true)
|
||||
|
||||
store.MigrateData()
|
||||
|
||||
options := store.setDefaultBackupOptions(getBackupRestoreOptions(store.commonBackupDir()))
|
||||
|
||||
if isFileExist(options.BackupPath) {
|
||||
t.Errorf("Backup file should not exist for dirty database; file=%s", options.BackupPath)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -78,135 +150,50 @@ func TestMigrateData(t *testing.T) {
|
||||
version := "2.15"
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
store.VersionService.UpdateVersion(&models.Version{SchemaVersion: version, Edition: int(portainer.PortainerCE)})
|
||||
store.MigrateData()
|
||||
err := store.MigrateData()
|
||||
if err == nil {
|
||||
t.Errorf("Expect migration to fail")
|
||||
}
|
||||
|
||||
store.Open()
|
||||
testVersion(store, version, t)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("MigrateData should fail to create backup if database file is set to updating", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
store.VersionService.StoreIsUpdating(true)
|
||||
store.MigrateData()
|
||||
func Test_getBackupRestoreOptions(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, false, true)
|
||||
|
||||
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
|
||||
// If the backup file is not blank, then it means a backup was created. We don't want that because we
|
||||
// only create a backup when the version changes.
|
||||
backupfilename := store.backupFilename()
|
||||
if exists, _ := store.fileService.FileExists(backupfilename); exists {
|
||||
t.Errorf("Backup file should not exist for dirty database")
|
||||
}
|
||||
})
|
||||
options := getBackupRestoreOptions(store.commonBackupDir())
|
||||
|
||||
t.Run("MigrateData should not create backup on startup if portainer version matches db", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
wantDir := store.commonBackupDir()
|
||||
if !strings.HasSuffix(options.BackupDir, wantDir) {
|
||||
log.Fatal().Str("got", options.BackupDir).Str("want", wantDir).Msg("incorrect backup dir")
|
||||
}
|
||||
|
||||
// Set migrator the count to match our migrations array (simulate no changes).
|
||||
// Should not create a backup
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
t.Errorf("Unable to read version from db: %s", err)
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
migratorParams := store.newMigratorParameters(v)
|
||||
m := migrator.NewMigrator(migratorParams)
|
||||
latestMigrations := m.LatestMigrations()
|
||||
|
||||
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
|
||||
v.MigratorCount = len(latestMigrations.MigrationFuncs)
|
||||
store.VersionService.UpdateVersion(v)
|
||||
}
|
||||
|
||||
store.MigrateData()
|
||||
|
||||
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
|
||||
// If the backup file is not blank, then it means a backup was created. We don't want that because we
|
||||
// only create a backup when the version changes.
|
||||
backupfilename := store.backupFilename()
|
||||
if exists, _ := store.fileService.FileExists(backupfilename); exists {
|
||||
t.Errorf("Backup file should not exist for dirty database")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MigrateData should create backup on startup if portainer version matches db and migrationFuncs counts differ", func(t *testing.T) {
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
// Set migrator count very large to simulate changes
|
||||
// Should not create a backup
|
||||
v, err := store.VersionService.Version()
|
||||
if err != nil {
|
||||
t.Errorf("Unable to read version from db: %s", err)
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
v.MigratorCount = 1000
|
||||
store.VersionService.UpdateVersion(v)
|
||||
store.MigrateData()
|
||||
|
||||
// If you get an error, it usually means that the backup folder doesn't exist (no backups). Expected!
|
||||
// If the backup file is not blank, then it means a backup was created. We don't want that because we
|
||||
// only create a backup when the version changes.
|
||||
backupfilename := store.backupFilename()
|
||||
if exists, _ := store.fileService.FileExists(backupfilename); !exists {
|
||||
t.Errorf("DB backup should exist and there should be no error")
|
||||
}
|
||||
})
|
||||
wantFilename := "portainer.db.bak"
|
||||
if options.BackupFileName != wantFilename {
|
||||
log.Fatal().Str("got", options.BackupFileName).Str("want", wantFilename).Msg("incorrect backup file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback(t *testing.T) {
|
||||
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
|
||||
version := "2.11"
|
||||
|
||||
v := models.Version{
|
||||
SchemaVersion: version,
|
||||
}
|
||||
|
||||
_, store := MustNewTestStore(t, false, false)
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
_, err := store.Backup("")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
v.SchemaVersion = "2.14"
|
||||
// Change the current edition
|
||||
err = store.VersionService.UpdateVersion(&v)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
err = store.Rollback(true)
|
||||
if err != nil {
|
||||
t.Logf("Rollback failed: %s", err)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
store.Open()
|
||||
testVersion(store, version, t)
|
||||
})
|
||||
|
||||
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
|
||||
version := "2.15"
|
||||
|
||||
v := models.Version{
|
||||
SchemaVersion: version,
|
||||
Edition: int(portainer.PortainerCE),
|
||||
}
|
||||
|
||||
version := models.Version{SchemaVersion: "2.4.0"}
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
_, err := store.Backup("")
|
||||
err := store.VersionService.UpdateVersion(&version)
|
||||
if err != nil {
|
||||
t.Errorf("Failed updating version: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.backupWithOptions(getBackupRestoreOptions(store.commonBackupDir()))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
v.SchemaVersion = "2.14"
|
||||
// Change the current edition
|
||||
err = store.VersionService.UpdateVersion(&v)
|
||||
// Change the current version
|
||||
version2 := models.Version{SchemaVersion: "2.6.0"}
|
||||
err = store.VersionService.UpdateVersion(&version2)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
@@ -218,11 +205,26 @@ func TestRollback(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
store.Open()
|
||||
testVersion(store, version, t)
|
||||
_, err = store.Open()
|
||||
if err != nil {
|
||||
t.Logf("Open failed: %s", err)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
testVersion(store, version.SchemaVersion, t)
|
||||
})
|
||||
}
|
||||
|
||||
// isFileExist is helper function to check for file existence
|
||||
func isFileExist(path string) bool {
|
||||
matches, err := filepath.Glob(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(matches) > 0
|
||||
}
|
||||
|
||||
// migrateDBTestHelper loads a json representation of a bolt database from srcPath,
|
||||
// parses it into a database, runs a migration on that database, and then
|
||||
// compares it with an expected output database.
|
||||
@@ -305,7 +307,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
|
||||
os.WriteFile(
|
||||
gotPath,
|
||||
gotJSON,
|
||||
0o600,
|
||||
0600,
|
||||
)
|
||||
t.Errorf(
|
||||
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",
|
||||
|
||||
@@ -23,3 +23,21 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
|
||||
|
||||
return migrator.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
// setUseCacheForDB110 sets the user cache to true for all users
|
||||
func (migrator *Migrator) setUserCacheForDB110() error {
|
||||
users, err := migrator.userService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
user := &users[i]
|
||||
user.UseCache = true
|
||||
if err := migrator.userService.Update(user.ID, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ func (m *Migrator) initMigrations() {
|
||||
)
|
||||
m.addMigrations("2.20",
|
||||
m.updateAppTemplatesVersionForDB110,
|
||||
m.setUserCacheForDB110,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
|
||||
@@ -68,7 +68,10 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
func (tx *StoreTx) Stack() dataservices.StackService { return nil }
|
||||
|
||||
func (tx *StoreTx) Stack() dataservices.StackService {
|
||||
return tx.store.StackService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Tag() dataservices.TagService {
|
||||
return tx.store.TagService.Tx(tx.tx)
|
||||
|
||||
@@ -669,7 +669,6 @@
|
||||
"snapshots": [
|
||||
{
|
||||
"Docker": {
|
||||
"ContainerCount": 0,
|
||||
"DockerSnapshotRaw": {
|
||||
"Containers": null,
|
||||
"Images": null,
|
||||
@@ -904,7 +903,7 @@
|
||||
"color": ""
|
||||
},
|
||||
"TokenIssueAt": 0,
|
||||
"UseCache": false,
|
||||
"UseCache": true,
|
||||
"Username": "admin"
|
||||
},
|
||||
{
|
||||
@@ -934,11 +933,11 @@
|
||||
"color": ""
|
||||
},
|
||||
"TokenIssueAt": 0,
|
||||
"UseCache": false,
|
||||
"UseCache": true,
|
||||
"Username": "prabhat"
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
var errUnsupportedEnvironmentType = errors.New("environment not supported")
|
||||
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
|
||||
|
||||
const (
|
||||
defaultDockerRequestTimeout = 60 * time.Second
|
||||
@@ -48,16 +42,9 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
|
||||
case portainer.AzureEnvironment:
|
||||
return nil, errUnsupportedEnvironmentType
|
||||
case portainer.AgentOnDockerEnvironment:
|
||||
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
|
||||
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
|
||||
case portainer.EdgeAgentOnDockerEnvironment:
|
||||
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
|
||||
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
|
||||
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
@@ -100,7 +87,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
|
||||
)
|
||||
}
|
||||
|
||||
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -120,73 +107,51 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
|
||||
headers[portainer.PortainerAgentTargetHeader] = nodeName
|
||||
}
|
||||
|
||||
opts := []client.Opt{
|
||||
tunnel, err := reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpointURL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
client.WithHTTPHeaders(headers),
|
||||
}
|
||||
|
||||
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
return client.NewClientWithOpts(opts...)
|
||||
)
|
||||
}
|
||||
|
||||
type NodeNameTransport struct {
|
||||
*http.Transport
|
||||
nodeNames map[string]string
|
||||
}
|
||||
|
||||
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.Transport.RoundTrip(req)
|
||||
if err != nil ||
|
||||
resp.StatusCode != http.StatusOK ||
|
||||
resp.ContentLength == 0 ||
|
||||
!strings.HasSuffix(req.URL.Path, "/images/json") {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return resp, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
var rs []struct {
|
||||
types.ImageSummary
|
||||
Portainer struct {
|
||||
Agent struct {
|
||||
NodeName string
|
||||
}
|
||||
}
|
||||
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(body, &rs); err != nil {
|
||||
return resp, nil
|
||||
headers := map[string]string{
|
||||
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
|
||||
portainer.PortainerAgentSignatureHeader: signature,
|
||||
}
|
||||
|
||||
t.nodeNames = make(map[string]string)
|
||||
for _, r := range rs {
|
||||
t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
|
||||
if nodeName != "" {
|
||||
headers[portainer.PortainerAgentTargetHeader] = nodeName
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (t *NodeNameTransport) NodeNames() map[string]string {
|
||||
return maps.Clone(t.nodeNames)
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
client.WithHTTPHeaders(headers),
|
||||
)
|
||||
}
|
||||
|
||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||
transport := &NodeNameTransport{
|
||||
Transport: &http.Transport{},
|
||||
}
|
||||
transport := &http.Transport{}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
|
||||
@@ -201,12 +201,9 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
|
||||
}
|
||||
}
|
||||
|
||||
if container.State == "healthy" {
|
||||
runningContainers++
|
||||
if strings.Contains(container.Status, "(healthy)") {
|
||||
healthyContainers++
|
||||
}
|
||||
|
||||
if container.State == "unhealthy" {
|
||||
} else if strings.Contains(container.Status, "(unhealthy)") {
|
||||
unhealthyContainers++
|
||||
}
|
||||
|
||||
@@ -225,7 +222,6 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
|
||||
snapshot.GpuUseAll = gpuUseAll
|
||||
snapshot.GpuUseList = gpuUseList
|
||||
|
||||
snapshot.ContainerCount = len(containers)
|
||||
snapshot.RunningContainerCount = runningContainers
|
||||
snapshot.StoppedContainerCount = stoppedContainers
|
||||
snapshot.HealthyContainerCount = healthyContainers
|
||||
|
||||
@@ -51,10 +51,6 @@ type (
|
||||
// Used only for EE
|
||||
// EnvVars is a list of environment variables to inject into the stack
|
||||
EnvVars []portainer.Pair
|
||||
|
||||
// Used only for EE async edge agent
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
ReadyRePullImage bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/testhelpers"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ func (service *Service) GetStackProjectPathByVersion(stackIdentifier string, ver
|
||||
}
|
||||
|
||||
if commitHash != "" {
|
||||
versionStr = commitHash
|
||||
versionStr = fmt.Sprintf("%s", commitHash)
|
||||
}
|
||||
return JoinPaths(service.wrapFileStore(ComposeStorePath), stackIdentifier, versionStr)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type authenticatePayload struct {
|
||||
|
||||
type authenticateResponse struct {
|
||||
// JWT token used to authenticate against the API
|
||||
JWT string `json:"jwt" example:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB"`
|
||||
JWT string `json:"jwt" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE"`
|
||||
}
|
||||
|
||||
func (payload *authenticatePayload) Validate(r *http.Request) error {
|
||||
@@ -200,7 +200,7 @@ func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settin
|
||||
|
||||
func teamExists(teamName string, ldapGroups []string) bool {
|
||||
for _, group := range ldapGroups {
|
||||
if strings.EqualFold(group, teamName) {
|
||||
if strings.ToLower(group) == strings.ToLower(teamName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func isValidNote(note string) bool {
|
||||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /custom_templates/create/string [post]
|
||||
// @router /custom_templates/string [post]
|
||||
func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*portainer.CustomTemplate, error) {
|
||||
var payload customTemplateFromFileContentPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
|
||||
@@ -8,11 +8,8 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/slices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @id CustomTemplateList
|
||||
@@ -24,7 +21,6 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param type query []int true "Template types" Enums(1,2,3)
|
||||
// @param edge query boolean false "Filter by edge templates"
|
||||
// @success 200 {array} portainer.CustomTemplate "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /custom_templates [get]
|
||||
@@ -34,8 +30,6 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid Custom template type", err)
|
||||
}
|
||||
|
||||
edge := retrieveEdgeParam(r)
|
||||
|
||||
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
|
||||
@@ -69,37 +63,9 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
customTemplates = filterByType(customTemplates, templateTypes)
|
||||
|
||||
if edge != nil {
|
||||
customTemplates = slices.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
|
||||
return customTemplate.EdgeTemplate == *edge
|
||||
})
|
||||
}
|
||||
|
||||
for i := range customTemplates {
|
||||
customTemplate := &customTemplates[i]
|
||||
if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil {
|
||||
customTemplate.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, customTemplates)
|
||||
}
|
||||
|
||||
func retrieveEdgeParam(r *http.Request) *bool {
|
||||
var edge *bool
|
||||
edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true)
|
||||
if edgeParam != "" {
|
||||
edgeVal, err := strconv.ParseBool(edgeParam)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed parsing edge param")
|
||||
return nil
|
||||
}
|
||||
|
||||
edge = &edgeVal
|
||||
}
|
||||
return edge
|
||||
}
|
||||
|
||||
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
|
||||
@@ -211,12 +211,10 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||
customTemplate.GitConfig = gitConfig
|
||||
} else {
|
||||
templateFolder := strconv.Itoa(customTemplateID)
|
||||
projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
|
||||
_, err = handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist updated custom template file on disk", err)
|
||||
}
|
||||
|
||||
customTemplate.ProjectPath = projectPath
|
||||
}
|
||||
|
||||
err = handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate)
|
||||
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
||||
"github.com/portainer/portainer/api/internal/set"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
)
|
||||
|
||||
type ImageResponse struct {
|
||||
@@ -50,12 +48,6 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
||||
}
|
||||
|
||||
// Extract the node name from the custom transport
|
||||
nodeNames := make(map[string]string)
|
||||
if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
|
||||
nodeNames = t.NodeNames()
|
||||
}
|
||||
|
||||
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: withUsage", err)
|
||||
@@ -82,12 +74,11 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
}
|
||||
|
||||
imagesList[i] = ImageResponse{
|
||||
Created: image.Created,
|
||||
NodeName: nodeNames[image.ID],
|
||||
ID: image.ID,
|
||||
Size: image.Size,
|
||||
Tags: image.RepoTags,
|
||||
Used: imageUsageSet.Contains(image.ID),
|
||||
Created: image.Created,
|
||||
ID: image.ID,
|
||||
Size: image.Size,
|
||||
Tags: image.RepoTags,
|
||||
Used: imageUsageSet.Contains(image.ID),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -189,3 +190,26 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
|
||||
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
||||
}
|
||||
|
||||
func newStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIds []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
|
||||
newStatus := make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||
for _, endpointID := range relatedEnvironmentIds {
|
||||
newEnvStatus := portainer.EdgeStackStatus{}
|
||||
|
||||
oldEnvStatus, ok := oldStatus[endpointID]
|
||||
if ok {
|
||||
newEnvStatus = oldEnvStatus
|
||||
}
|
||||
|
||||
newEnvStatus.Status = []portainer.EdgeStackDeploymentStatus{
|
||||
{
|
||||
Time: time.Now().Unix(),
|
||||
Type: portainer.EdgeStackStatusPending,
|
||||
},
|
||||
}
|
||||
|
||||
newStatus[endpointID] = newEnvStatus
|
||||
}
|
||||
|
||||
return newStatus
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
@@ -24,6 +26,8 @@ type Handler struct {
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
}
|
||||
|
||||
const contextKey = "edgeStack_item"
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||
h := &Handler{
|
||||
@@ -58,6 +62,35 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(stackFolder string, projectPath, composePath string, relatedEndpointIds []portainer.EndpointID) (manifestPath string, err error) {
|
||||
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
|
||||
}
|
||||
|
||||
if !hasKubeEndpoint {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
composeConfig, err := handler.FileService.GetFileContent(projectPath, composePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
|
||||
}
|
||||
|
||||
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
|
||||
}
|
||||
|
||||
komposeFileName := filesystem.ManifestFileDefaultName
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, komposeFileName, kompose)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store kube manifest file: %w", err)
|
||||
}
|
||||
|
||||
return komposeFileName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
httpErr := httperror.InternalServerError(msg, err)
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ package endpoints
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints/{id}/docker/v2/browse/put [post]
|
||||
//
|
||||
//lint:ignore U1000 Ignore unused code, for documentation purposes
|
||||
func _fileBrowseFileUploadV2() {
|
||||
// dummy function to make swag pick up the above docs for the following REST call
|
||||
// POST request on /browse/put?volumeID=:id
|
||||
|
||||
@@ -2,6 +2,7 @@ package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -29,7 +30,7 @@ const (
|
||||
// @produce json
|
||||
// @param start query int false "Start searching from"
|
||||
// @param limit query int false "Limit results to this value"
|
||||
// @param sort query sortKey false "Sort results by this value" Enum("Name", "Group", "Status", "LastCheckIn", "EdgeID")
|
||||
// @param sort query int false "Sort results by this value"
|
||||
// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc")
|
||||
// @param search query string false "Search query"
|
||||
// @param groupIds query []int false "List environments(endpoints) of these groups"
|
||||
@@ -97,7 +98,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
return httperror.InternalServerError("Unable to filter endpoints", err)
|
||||
}
|
||||
|
||||
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
|
||||
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
|
||||
|
||||
filteredEndpointCount := len(filteredEndpoints)
|
||||
|
||||
@@ -146,6 +147,46 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
||||
return endpoints[start:end]
|
||||
}
|
||||
|
||||
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
|
||||
|
||||
switch sortField {
|
||||
case "Name":
|
||||
if isSortDesc {
|
||||
sort.Stable(sort.Reverse(EndpointsByName(endpoints)))
|
||||
} else {
|
||||
sort.Stable(EndpointsByName(endpoints))
|
||||
}
|
||||
|
||||
case "Group":
|
||||
endpointGroupNames := make(map[portainer.EndpointGroupID]string, 0)
|
||||
for _, group := range endpointGroups {
|
||||
endpointGroupNames[group.ID] = group.Name
|
||||
}
|
||||
|
||||
endpointsByGroup := EndpointsByGroup{
|
||||
endpointGroupNames: endpointGroupNames,
|
||||
endpoints: endpoints,
|
||||
}
|
||||
|
||||
if isSortDesc {
|
||||
sort.Stable(sort.Reverse(endpointsByGroup))
|
||||
} else {
|
||||
sort.Stable(endpointsByGroup)
|
||||
}
|
||||
|
||||
case "Status":
|
||||
if isSortDesc {
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
return endpoints[i].Status > endpoints[j].Status
|
||||
})
|
||||
} else {
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
return endpoints[i].Status < endpoints[j].Status
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
|
||||
var endpointGroup portainer.EndpointGroup
|
||||
for _, group := range groups {
|
||||
|
||||
@@ -1,94 +1,46 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/fvbommel/sortorder"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type comp[T any] func(a, b T) int
|
||||
type EndpointsByName []portainer.Endpoint
|
||||
|
||||
func stringComp(a, b string) int {
|
||||
if sortorder.NaturalLess(a, b) {
|
||||
return -1
|
||||
} else if sortorder.NaturalLess(b, a) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
func (e EndpointsByName) Len() int {
|
||||
return len(e)
|
||||
}
|
||||
|
||||
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
|
||||
if sortField == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var less comp[portainer.Endpoint]
|
||||
switch sortField {
|
||||
case sortKeyName:
|
||||
less = func(a, b portainer.Endpoint) int {
|
||||
return stringComp(a.Name, b.Name)
|
||||
}
|
||||
|
||||
case sortKeyGroup:
|
||||
environmentGroupNames := make(map[portainer.EndpointGroupID]string, 0)
|
||||
for _, group := range environmentGroups {
|
||||
environmentGroupNames[group.ID] = group.Name
|
||||
}
|
||||
|
||||
// set the "unassigned" group name to be empty string
|
||||
environmentGroupNames[1] = ""
|
||||
|
||||
less = func(a, b portainer.Endpoint) int {
|
||||
aGroup := environmentGroupNames[a.GroupID]
|
||||
bGroup := environmentGroupNames[b.GroupID]
|
||||
|
||||
return stringComp(aGroup, bGroup)
|
||||
}
|
||||
|
||||
case sortKeyStatus:
|
||||
less = func(a, b portainer.Endpoint) int {
|
||||
return int(a.Status - b.Status)
|
||||
}
|
||||
|
||||
case sortKeyLastCheckInDate:
|
||||
less = func(a, b portainer.Endpoint) int {
|
||||
return int(a.LastCheckInDate - b.LastCheckInDate)
|
||||
}
|
||||
case sortKeyEdgeID:
|
||||
less = func(a, b portainer.Endpoint) int {
|
||||
return stringComp(a.EdgeID, b.EdgeID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int {
|
||||
mul := 1
|
||||
if isSortDesc {
|
||||
mul = -1
|
||||
}
|
||||
|
||||
return less(a, b) * mul
|
||||
})
|
||||
|
||||
func (e EndpointsByName) Swap(i, j int) {
|
||||
e[i], e[j] = e[j], e[i]
|
||||
}
|
||||
|
||||
type sortKey string
|
||||
func (e EndpointsByName) Less(i, j int) bool {
|
||||
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
|
||||
}
|
||||
|
||||
const (
|
||||
sortKeyName sortKey = "Name"
|
||||
sortKeyGroup sortKey = "Group"
|
||||
sortKeyStatus sortKey = "Status"
|
||||
sortKeyLastCheckInDate sortKey = "LastCheckIn"
|
||||
sortKeyEdgeID sortKey = "EdgeID"
|
||||
)
|
||||
type EndpointsByGroup struct {
|
||||
endpointGroupNames map[portainer.EndpointGroupID]string
|
||||
endpoints []portainer.Endpoint
|
||||
}
|
||||
|
||||
func getSortKey(sortField string) sortKey {
|
||||
fieldAsSortKey := sortKey(sortField)
|
||||
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID}, fieldAsSortKey) {
|
||||
return fieldAsSortKey
|
||||
func (e EndpointsByGroup) Len() int {
|
||||
return len(e.endpoints)
|
||||
}
|
||||
|
||||
func (e EndpointsByGroup) Swap(i, j int) {
|
||||
e.endpoints[i], e.endpoints[j] = e.endpoints[j], e.endpoints[i]
|
||||
}
|
||||
|
||||
func (e EndpointsByGroup) Less(i, j int) bool {
|
||||
if e.endpoints[i].GroupID == e.endpoints[j].GroupID {
|
||||
return false
|
||||
}
|
||||
|
||||
return ""
|
||||
groupA := e.endpointGroupNames[e.endpoints[i].GroupID]
|
||||
groupB := e.endpointGroupNames[e.endpoints[j].GroupID]
|
||||
|
||||
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/slices"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSortEndpointsByField(t *testing.T) {
|
||||
environments := []portainer.Endpoint{
|
||||
{ID: 0, Name: "Environment 1", GroupID: 1, Status: 1, LastCheckInDate: 3, EdgeID: "edge32"},
|
||||
{ID: 1, Name: "Environment 2", GroupID: 2, Status: 2, LastCheckInDate: 6, EdgeID: "edge57"},
|
||||
{ID: 2, Name: "Environment 3", GroupID: 1, Status: 3, LastCheckInDate: 2, EdgeID: "test87"},
|
||||
{ID: 3, Name: "Environment 4", GroupID: 2, Status: 4, LastCheckInDate: 1, EdgeID: "abc123"},
|
||||
}
|
||||
|
||||
environmentGroups := []portainer.EndpointGroup{
|
||||
{ID: 1, Name: "Group 1"},
|
||||
{ID: 2, Name: "Group 2"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sortField sortKey
|
||||
isSortDesc bool
|
||||
expected []portainer.EndpointID
|
||||
}{
|
||||
{
|
||||
name: "sort without value",
|
||||
sortField: "",
|
||||
expected: []portainer.EndpointID{
|
||||
environments[0].ID,
|
||||
environments[1].ID,
|
||||
environments[2].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by name ascending",
|
||||
sortField: "Name",
|
||||
isSortDesc: false,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[0].ID,
|
||||
environments[1].ID,
|
||||
environments[2].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by name descending",
|
||||
sortField: "Name",
|
||||
isSortDesc: true,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[3].ID,
|
||||
environments[2].ID,
|
||||
environments[1].ID,
|
||||
environments[0].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by group name ascending",
|
||||
sortField: "Group",
|
||||
isSortDesc: false,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[0].ID,
|
||||
environments[2].ID,
|
||||
environments[1].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by group name descending",
|
||||
sortField: "Group",
|
||||
isSortDesc: true,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[1].ID,
|
||||
environments[3].ID,
|
||||
environments[0].ID,
|
||||
environments[2].ID,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "sort by status ascending",
|
||||
sortField: "Status",
|
||||
isSortDesc: false,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[0].ID,
|
||||
environments[1].ID,
|
||||
environments[2].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by status descending",
|
||||
sortField: "Status",
|
||||
isSortDesc: true,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[3].ID,
|
||||
environments[2].ID,
|
||||
environments[1].ID,
|
||||
environments[0].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by last check-in ascending",
|
||||
sortField: "LastCheckIn",
|
||||
isSortDesc: false,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[3].ID,
|
||||
environments[2].ID,
|
||||
environments[0].ID,
|
||||
environments[1].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by last check-in descending",
|
||||
sortField: "LastCheckIn",
|
||||
isSortDesc: true,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[1].ID,
|
||||
environments[0].ID,
|
||||
environments[2].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by edge ID ascending",
|
||||
sortField: "EdgeID",
|
||||
expected: []portainer.EndpointID{
|
||||
environments[3].ID,
|
||||
environments[0].ID,
|
||||
environments[1].ID,
|
||||
environments[2].ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sort by edge ID descending",
|
||||
sortField: "EdgeID",
|
||||
isSortDesc: true,
|
||||
expected: []portainer.EndpointID{
|
||||
environments[2].ID,
|
||||
environments[1].ID,
|
||||
environments[0].ID,
|
||||
environments[3].ID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
sortEnvironmentsByField(environments, environmentGroups, "Name", false) // reset to default sort order
|
||||
|
||||
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc)
|
||||
|
||||
is.Equal(tt.expected, getEndpointIDs(environments))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID {
|
||||
return slices.Map(environments, func(environment portainer.Endpoint) portainer.EndpointID {
|
||||
return environment.ID
|
||||
})
|
||||
}
|
||||
@@ -8,22 +8,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id getKubernetesConfigMapsAndSecrets
|
||||
// @summary Get ConfigMaps and Secrets
|
||||
// @description Get all ConfigMaps and Secrets for a given namespace
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @param namespace path string true "Namespace name"
|
||||
// @success 200 {array} []kubernetes.K8sConfigMapOrSecret "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/configuration [get]
|
||||
func (handler *Handler) getKubernetesConfigMapsAndSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
|
||||
@@ -107,7 +107,6 @@ func kubeOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set(portainer.PortainerCacheHeader, "true")
|
||||
next.ServeHTTP(rw, request)
|
||||
})
|
||||
}
|
||||
@@ -126,7 +125,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
if !ok {
|
||||
return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil)
|
||||
}
|
||||
@@ -153,7 +152,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
if ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -213,7 +212,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Re
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment (Endpoint) identifier"
|
||||
// @param namespace path string true "Namespace"
|
||||
// @param body body models.K8sNamespaceDetails true "Namespace configuration details"
|
||||
// @success 200 {string} string "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
|
||||
@@ -47,7 +47,7 @@ func NewHandler(bouncer security.BouncerService,
|
||||
authenticatedRouter := router.PathPrefix("/").Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
authenticatedRouter.Handle("/version", httperror.LoggerHandler(h.version)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/version", http.HandlerFunc(h.version)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/nodes", httperror.LoggerHandler(h.systemNodesCount)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/info", httperror.LoggerHandler(h.systemInfo)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@ package system
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
@@ -35,8 +32,6 @@ type BuildInfo struct {
|
||||
YarnVersion string
|
||||
WebpackVersion string
|
||||
GoVersion string
|
||||
GitCommit string
|
||||
Env []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// @id systemVersion
|
||||
@@ -49,11 +44,7 @@ type BuildInfo struct {
|
||||
// @produce json
|
||||
// @success 200 {object} versionResponse "Success"
|
||||
// @router /system/version [get]
|
||||
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
isAdmin, err := security.IsAdmin(r)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access Portainer", err)
|
||||
}
|
||||
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result := &versionResponse{
|
||||
ServerVersion: portainer.APIVersion,
|
||||
@@ -66,21 +57,16 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
|
||||
YarnVersion: build.YarnVersion,
|
||||
WebpackVersion: build.WebpackVersion,
|
||||
GoVersion: build.GoVersion,
|
||||
GitCommit: build.GitCommit,
|
||||
},
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
result.Build.Env = os.Environ()
|
||||
}
|
||||
|
||||
latestVersion := GetLatestVersion()
|
||||
if HasNewerVersion(portainer.APIVersion, latestVersion) {
|
||||
result.UpdateAvailable = true
|
||||
result.LatestVersion = latestVersion
|
||||
}
|
||||
|
||||
return response.JSON(w, &result)
|
||||
response.JSON(w, &result)
|
||||
}
|
||||
|
||||
func GetLatestVersion() string {
|
||||
|
||||
@@ -65,6 +65,7 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe
|
||||
user := &portainer.User{
|
||||
Username: payload.Username,
|
||||
Role: portainer.AdministratorRole,
|
||||
UseCache: true,
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
|
||||
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
|
||||
errCryptoHashFailure = errors.New("Unable to hash data")
|
||||
errWrongPassword = errors.New("Wrong password")
|
||||
)
|
||||
|
||||
func hideFields(user *portainer.User) {
|
||||
|
||||
@@ -65,6 +65,7 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
|
||||
user = &portainer.User{
|
||||
Username: payload.Username,
|
||||
Role: portainer.UserRole(payload.Role),
|
||||
UseCache: true,
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
|
||||
@@ -15,22 +15,18 @@ import (
|
||||
)
|
||||
|
||||
type userAccessTokenCreatePayload struct {
|
||||
Password string `validate:"required" example:"password" json:"password"`
|
||||
Description string `validate:"required" example:"github-api-key" json:"description"`
|
||||
}
|
||||
|
||||
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Password) {
|
||||
return errors.New("invalid password: cannot be empty")
|
||||
}
|
||||
if govalidator.IsNull(payload.Description) {
|
||||
return errors.New("invalid description: cannot be empty")
|
||||
return errors.New("invalid description. cannot be empty")
|
||||
}
|
||||
if govalidator.HasWhitespaceOnly(payload.Description) {
|
||||
return errors.New("invalid description: cannot contain only whitespaces")
|
||||
return errors.New("invalid description. cannot contain only whitespaces")
|
||||
}
|
||||
if govalidator.MinStringLength(payload.Description, "128") {
|
||||
return errors.New("invalid description: cannot be longer than 128 characters")
|
||||
return errors.New("invalid description. cannot be longer than 128 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -86,12 +82,7 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
|
||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
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"))
|
||||
return httperror.BadRequest("Unable to find a user", err)
|
||||
}
|
||||
|
||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||
|
||||
@@ -25,7 +25,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
// create admin and standard user(s)
|
||||
adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole}
|
||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().Create(adminUser)
|
||||
is.NoError(err, "error creating admin user")
|
||||
|
||||
@@ -43,14 +43,13 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
|
||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||
h.DataStore = store
|
||||
h.CryptoService = testhelpers.NewCryptoService()
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user successfully generates API key", func(t *testing.T) {
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"}
|
||||
data := userAccessTokenCreatePayload{Description: "test-token"}
|
||||
payload, err := json.Marshal(data)
|
||||
is.NoError(err)
|
||||
|
||||
@@ -73,7 +72,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"}
|
||||
data := userAccessTokenCreatePayload{Description: "test-token-admin"}
|
||||
payload, err := json.Marshal(data)
|
||||
is.NoError(err)
|
||||
|
||||
@@ -93,7 +92,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
|
||||
is.NoError(err)
|
||||
|
||||
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"}
|
||||
data := userAccessTokenCreatePayload{Description: "test-token-fails"}
|
||||
payload, err := json.Marshal(data)
|
||||
is.NoError(err)
|
||||
|
||||
@@ -119,23 +118,23 @@ func Test_userAccessTokenCreatePayload(t *testing.T) {
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"},
|
||||
payload: userAccessTokenCreatePayload{Description: "test-token"},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: ""},
|
||||
payload: userAccessTokenCreatePayload{Description: ""},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"},
|
||||
payload: userAccessTokenCreatePayload{Description: "test token"},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "},
|
||||
payload: userAccessTokenCreatePayload{Description: "test-token "},
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
payload: userAccessTokenCreatePayload{Password: "password", Description: `
|
||||
payload: userAccessTokenCreatePayload{Description: `
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
this string is longer than 128 characters and hence this will fail.
|
||||
|
||||
@@ -64,5 +64,5 @@ func (handler *Handler) userGetAccessTokens(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// hideAPIKeyFields remove the digest from the API key (it is not needed in the response)
|
||||
func hideAPIKeyFields(apiKey *portainer.APIKey) {
|
||||
apiKey.Digest = ""
|
||||
apiKey.Digest = nil
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func Test_userGetAccessTokens(t *testing.T) {
|
||||
|
||||
is.Len(resp, 1)
|
||||
if len(resp) == 1 {
|
||||
is.Equal(resp[0].Digest, "")
|
||||
is.Nil(resp[0].Digest)
|
||||
is.Equal(apiKey.ID, resp[0].ID)
|
||||
is.Equal(apiKey.UserID, resp[0].UserID)
|
||||
is.Equal(apiKey.Prefix, resp[0].Prefix)
|
||||
@@ -129,10 +129,10 @@ func Test_hideAPIKeyFields(t *testing.T) {
|
||||
UserID: 2,
|
||||
Prefix: "abc",
|
||||
Description: "test",
|
||||
Digest: "",
|
||||
Digest: nil,
|
||||
}
|
||||
|
||||
hideAPIKeyFields(apiKey)
|
||||
|
||||
is.Equal(apiKey.Digest, "", "digest should be cleared when hiding api key fields")
|
||||
is.Nil(apiKey.Digest, "digest should be cleared when hiding api key fields")
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
|
||||
// @tags webhooks
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
|
||||
// @param filters query webhookListOperationFilters false "Filters"
|
||||
// @success 200 {array} portainer.Webhook
|
||||
// @failure 400
|
||||
// @failure 500
|
||||
|
||||
@@ -13,15 +13,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Note: context keys must be distinct types to prevent collisions. They are NOT key/value map's internally
|
||||
// See: https://go.dev/blog/context#TOC_3.2.
|
||||
|
||||
// This avoids staticcheck error:
|
||||
// SA1029: should not use built-in type string as key for value; define your own type to avoid collisions (staticcheck)
|
||||
// https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint
|
||||
type key int
|
||||
|
||||
const contextEndpoint key = 0
|
||||
const contextEndpoint = "endpoint"
|
||||
|
||||
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -57,11 +57,5 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
response, err := transport.baseTransport.RoundTrip(request)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Header.Set(portainer.PortainerCacheHeader, "true")
|
||||
|
||||
return response, err
|
||||
return transport.baseTransport.RoundTrip(request)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,3 @@ func Map[T, U any](s []T, f func(T) U) []U {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true
|
||||
func Filter[T any](s []T, predicate func(T) bool) []T {
|
||||
n := 0
|
||||
for _, v := range s {
|
||||
if predicate(v) {
|
||||
s[n] = v
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package slices
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type filterTestCase[T any] struct {
|
||||
name string
|
||||
input []T
|
||||
expected []T
|
||||
predicate func(T) bool
|
||||
}
|
||||
|
||||
func TestFilter(t *testing.T) {
|
||||
|
||||
intTestCases := []filterTestCase[int]{
|
||||
{
|
||||
name: "Filter even numbers",
|
||||
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
expected: []int{2, 4, 6, 8},
|
||||
|
||||
predicate: func(n int) bool {
|
||||
return n%2 == 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Filter odd numbers",
|
||||
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
expected: []int{1, 3, 5, 7, 9},
|
||||
|
||||
predicate: func(n int) bool {
|
||||
return n%2 != 0
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTestCases(t, intTestCases)
|
||||
|
||||
stringTestCases := []filterTestCase[string]{
|
||||
{
|
||||
name: "Filter strings starting with 'A'",
|
||||
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
|
||||
expected: []string{"Apple", "Avocado", "Apricot"},
|
||||
predicate: func(s string) bool {
|
||||
return s[0] == 'A'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Filter strings longer than 5 characters",
|
||||
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
|
||||
expected: []string{"Banana", "Avocado", "Grapes", "Apricot"},
|
||||
predicate: func(s string) bool {
|
||||
return len(s) > 5
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTestCases(t, stringTestCases)
|
||||
|
||||
}
|
||||
|
||||
func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
result := Filter(testCase.input, testCase.predicate)
|
||||
|
||||
is.Equal(len(testCase.expected), len(result))
|
||||
is.ElementsMatch(testCase.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
intTestCases := []struct {
|
||||
name string
|
||||
input []int
|
||||
expected []string
|
||||
mapper func(int) string
|
||||
}{
|
||||
{
|
||||
name: "Map integers to strings",
|
||||
input: []int{1, 2, 3, 4, 5},
|
||||
expected: []string{"1", "2", "3", "4", "5"},
|
||||
mapper: func(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMapTestCases(t, intTestCases)
|
||||
|
||||
stringTestCases := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []int
|
||||
mapper func(string) int
|
||||
}{
|
||||
{
|
||||
name: "Map strings to integers",
|
||||
input: []string{"1", "2", "3", "4", "5"},
|
||||
expected: []int{1, 2, 3, 4, 5},
|
||||
mapper: func(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMapTestCases(t, stringTestCases)
|
||||
}
|
||||
|
||||
func runMapTestCases[T, U any](t *testing.T, testCases []struct {
|
||||
name string
|
||||
input []T
|
||||
expected []U
|
||||
mapper func(T) U
|
||||
}) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
result := Map(testCase.input, testCase.mapper)
|
||||
|
||||
is.Equal(len(testCase.expected), len(result))
|
||||
is.ElementsMatch(testCase.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package testhelpers
|
||||
|
||||
// Service represents a service for encrypting/hashing data.
|
||||
type cryptoService struct{}
|
||||
|
||||
func NewCryptoService() *cryptoService {
|
||||
return &cryptoService{}
|
||||
}
|
||||
|
||||
func (*cryptoService) Hash(data string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (*cryptoService) CompareHashAndData(hash string, data string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package testhelpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -36,7 +37,7 @@ type testDatastore struct {
|
||||
pendingActionsService dataservices.PendingActionsService
|
||||
}
|
||||
|
||||
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
|
||||
func (d *testDatastore) BackupTo(io.Writer) error { return nil }
|
||||
func (d *testDatastore) Open() (bool, error) { return false, nil }
|
||||
func (d *testDatastore) Init() error { return nil }
|
||||
func (d *testDatastore) Close() error { return nil }
|
||||
@@ -56,11 +57,9 @@ func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { re
|
||||
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||
return d.fdoProfile
|
||||
}
|
||||
|
||||
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
||||
return d.endpointRelation
|
||||
}
|
||||
|
||||
func (d *testDatastore) HelmUserRepository() dataservices.HelmUserRepositoryService {
|
||||
return d.helmUserRepository
|
||||
}
|
||||
@@ -95,7 +94,6 @@ func (d *testDatastore) IsErrObjectNotFound(e error) bool {
|
||||
func (d *testDatastore) Export(filename string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *testDatastore) Import(filename string) (err error) {
|
||||
return nil
|
||||
}
|
||||
@@ -121,12 +119,10 @@ func (s *stubSettingsService) BucketName() string { return "settings" }
|
||||
func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
|
||||
return s.settings, nil
|
||||
}
|
||||
|
||||
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
|
||||
s.settings = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
d.settings = &stubSettingsService{
|
||||
@@ -166,19 +162,15 @@ func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.j
|
||||
func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
|
||||
return nil
|
||||
}
|
||||
@@ -200,7 +192,6 @@ func (s *stubEndpointRelationService) BucketName() string { return "endpoint_rel
|
||||
func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||
return s.relations, nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) {
|
||||
for _, relation := range s.relations {
|
||||
if relation.EndpointID == ID {
|
||||
@@ -210,11 +201,9 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == ID {
|
||||
@@ -224,7 +213,6 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||
return nil
|
||||
}
|
||||
@@ -319,7 +307,7 @@ func (s *stubEndpointService) GetNextIdentifier() int {
|
||||
}
|
||||
|
||||
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
|
||||
endpoints := make([]portainer.Endpoint, 0)
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, e := range s.endpoints {
|
||||
for t := range e.TeamAccessPolicies {
|
||||
|
||||
@@ -257,6 +257,32 @@ func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*re
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Insecure = true
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &agentHeaderRoundTripper{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
|
||||
return kubernetes.NewForConfig(config)
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
|
||||
config, err := factory.CreateConfig(endpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -241,10 +241,7 @@ func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) err
|
||||
// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error {
|
||||
ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace)
|
||||
ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ingress netv1.Ingress
|
||||
|
||||
ingress.Name = info.Name
|
||||
ingress.Namespace = info.Namespace
|
||||
@@ -281,7 +278,6 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
|
||||
})
|
||||
}
|
||||
|
||||
ingress.Spec.Rules = make([]netv1.IngressRule, 0)
|
||||
for rule, paths := range rules {
|
||||
ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{
|
||||
Host: rule,
|
||||
@@ -303,6 +299,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
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
|
||||
|
||||
for _, item := range nodes.Items {
|
||||
cpu := item.Status.Allocatable.Cpu().MilliValue()
|
||||
memory := item.Status.Allocatable.Memory().Value() // bytes
|
||||
memory := item.Status.Allocatable.Memory().Value()
|
||||
|
||||
nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{
|
||||
CPU: cpu,
|
||||
@@ -57,7 +57,7 @@ func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitE
|
||||
memory := int64(0)
|
||||
for _, node := range nodes.Items {
|
||||
limits.CPU += node.Status.Allocatable.Cpu().MilliValue()
|
||||
memory += node.Status.Allocatable.Memory().Value() // bytes
|
||||
memory += node.Status.Allocatable.Memory().Value()
|
||||
}
|
||||
limits.Memory = memory / 1000000 // B to MB
|
||||
|
||||
|
||||
@@ -147,11 +147,11 @@ func addResourceLabels(yamlDoc interface{}, appLabels map[string]string) {
|
||||
}
|
||||
|
||||
for _, v := range m {
|
||||
switch v := v.(type) {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}:
|
||||
addResourceLabels(v, appLabels)
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
for _, item := range v.([]interface{}) {
|
||||
addResourceLabels(item, appLabels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ type (
|
||||
// Authorizations represents a set of authorizations associated to a role
|
||||
Authorizations map[Authorization]bool
|
||||
|
||||
// AutoUpdateSettings represents the git auto sync config for stack deployment
|
||||
//AutoUpdateSettings represents the git auto sync config for stack deployment
|
||||
AutoUpdateSettings struct {
|
||||
// Auto update interval
|
||||
Interval string `example:"1m30s"`
|
||||
@@ -215,7 +215,6 @@ type (
|
||||
Swarm bool `json:"Swarm"`
|
||||
TotalCPU int `json:"TotalCPU"`
|
||||
TotalMemory int64 `json:"TotalMemory"`
|
||||
ContainerCount int `json:"ContainerCount"`
|
||||
RunningContainerCount int `json:"RunningContainerCount"`
|
||||
StoppedContainerCount int `json:"StoppedContainerCount"`
|
||||
HealthyContainerCount int `json:"HealthyContainerCount"`
|
||||
@@ -312,7 +311,7 @@ type (
|
||||
ConfigHash string `json:"ConfigHash"`
|
||||
}
|
||||
|
||||
// EdgeStack represents an edge stack
|
||||
//EdgeStack represents an edge stack
|
||||
EdgeStack struct {
|
||||
// EdgeStack Identifier
|
||||
ID EdgeStackID `json:"Id" example:"1"`
|
||||
@@ -336,7 +335,7 @@ type (
|
||||
|
||||
EdgeStackDeploymentType int
|
||||
|
||||
// EdgeStackID represents an edge stack id
|
||||
//EdgeStackID represents an edge stack id
|
||||
EdgeStackID int
|
||||
|
||||
EdgeStackStatusDetails struct {
|
||||
@@ -349,14 +348,12 @@ type (
|
||||
ImagesPulled bool
|
||||
}
|
||||
|
||||
// EdgeStackStatus represents an edge stack status
|
||||
//EdgeStackStatus represents an edge stack status
|
||||
EdgeStackStatus struct {
|
||||
Status []EdgeStackDeploymentStatus
|
||||
EndpointID EndpointID
|
||||
// EE only feature
|
||||
DeploymentInfo StackDeploymentInfo
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
ReadyRePullImage bool
|
||||
|
||||
// Deprecated
|
||||
Details EdgeStackStatusDetails
|
||||
@@ -375,7 +372,7 @@ type (
|
||||
RollbackTo *int
|
||||
}
|
||||
|
||||
// EdgeStackStatusType represents an edge stack status type
|
||||
//EdgeStackStatusType represents an edge stack status type
|
||||
EdgeStackStatusType int
|
||||
|
||||
PendingActionsID int
|
||||
@@ -908,7 +905,7 @@ type (
|
||||
Prefix string `json:"prefix"` // API key identifier (7 char prefix)
|
||||
DateCreated int64 `json:"dateCreated"` // Unix timestamp (UTC) when the API key was created
|
||||
LastUsed int64 `json:"lastUsed"` // Unix timestamp (UTC) when the API key was last used
|
||||
Digest string `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key
|
||||
Digest []byte `json:"digest,omitempty"` // Digest represents SHA256 hash of the raw API key
|
||||
}
|
||||
|
||||
// Schedule represents a scheduled job.
|
||||
@@ -1641,8 +1638,6 @@ const (
|
||||
WebSocketKeepAlive = 1 * time.Hour
|
||||
// AuthCookieName is the name of the cookie used to store the JWT token
|
||||
AuthCookieKey = "portainer_api_key"
|
||||
// PortainerCacheHeader is used to enabled FE caching for Kubernetes resources
|
||||
PortainerCacheHeader = "X-Portainer-Cache"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
@@ -1660,7 +1655,7 @@ const (
|
||||
AuthenticationInternal
|
||||
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
|
||||
AuthenticationLDAP
|
||||
// AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
|
||||
//AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server)
|
||||
AuthenticationOAuth
|
||||
)
|
||||
|
||||
@@ -1700,13 +1695,13 @@ const (
|
||||
const (
|
||||
// EdgeStackStatusPending represents a pending edge stack
|
||||
EdgeStackStatusPending EdgeStackStatusType = iota
|
||||
// EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
|
||||
//EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
|
||||
EdgeStackStatusDeploymentReceived
|
||||
// EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
|
||||
//EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
|
||||
EdgeStackStatusError
|
||||
// EdgeStackStatusAcknowledged represents an acknowledged edge stack
|
||||
//EdgeStackStatusAcknowledged represents an acknowledged edge stack
|
||||
EdgeStackStatusAcknowledged
|
||||
// EdgeStackStatusRemoved represents a removed edge stack
|
||||
//EdgeStackStatusRemoved represents a removed edge stack
|
||||
EdgeStackStatusRemoved
|
||||
// StatusRemoteUpdateSuccess represents a successfully updated edge stack
|
||||
EdgeStackStatusRemoteUpdateSuccess
|
||||
|
||||
@@ -3,7 +3,6 @@ package deployments
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
type StackAuthorMissingErr struct {
|
||||
@@ -29,68 +27,61 @@ func (e *StackAuthorMissingErr) Error() string {
|
||||
return fmt.Sprintf("stack's %v author %s is missing", e.stackID, e.authorName)
|
||||
}
|
||||
|
||||
var singleflightGroup = &singleflight.Group{}
|
||||
var errDoNothing = errors.New("do nothing")
|
||||
|
||||
// RedeployWhenChanged pull and redeploy the stack when git repo changed
|
||||
// Stack will always be redeployed if force deployment is set to true
|
||||
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error {
|
||||
stack, err := datastore.Stack().Read(stackID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != "" {
|
||||
return redeployWhenChanged(stack, deployer, datastore, gitService)
|
||||
}
|
||||
|
||||
// Polling
|
||||
_, err, _ = singleflightGroup.Do(strconv.Itoa(int(stackID)), func() (any, error) {
|
||||
return nil, redeployWhenChanged(stack, deployer, datastore, gitService)
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func redeployWhenChanged(stack *portainer.Stack, deployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) error {
|
||||
stackID := stack.ID
|
||||
|
||||
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return nil // do nothing if it isn't a git-based stack
|
||||
}
|
||||
var stack *portainer.Stack
|
||||
var endpoint *portainer.Endpoint
|
||||
var user *portainer.User
|
||||
var registries []portainer.Registry
|
||||
|
||||
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(
|
||||
errors.WithMessagef(err,
|
||||
"failed to find the environment %v associated to the stack %v",
|
||||
stack.EndpointID,
|
||||
stack.ID,
|
||||
),
|
||||
)
|
||||
err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
|
||||
stack, err = tx.Stack().Read(stackID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
|
||||
}
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return errDoNothing // do nothing if it isn't a git-based stack
|
||||
}
|
||||
|
||||
endpoint, err = tx.Endpoint().Endpoint(stack.EndpointID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(
|
||||
errors.WithMessagef(err,
|
||||
"failed to find the environment %v associated to the stack %v",
|
||||
stack.EndpointID,
|
||||
stack.ID,
|
||||
),
|
||||
)
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
|
||||
}
|
||||
|
||||
user, err = validateAuthor(tx, stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registries, err = getUserRegistries(tx, user, endpoint.ID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(err)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
if errors.Is(err, errDoNothing) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
|
||||
}
|
||||
|
||||
author := stack.UpdatedBy
|
||||
if author == "" {
|
||||
author = stack.CreatedBy
|
||||
}
|
||||
|
||||
user, err := datastore.User().UserByUsername(author)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Int("stack_id", int(stackID)).
|
||||
Str("author", author).
|
||||
Str("stack", stack.Name).
|
||||
Int("endpoint_id", int(stack.EndpointID)).
|
||||
Msg("cannot auto update a stack, stack author user is missing")
|
||||
|
||||
return &StackAuthorMissingErr{int(stack.ID), author}
|
||||
return err
|
||||
}
|
||||
|
||||
if !isEnvironmentOnline(endpoint) {
|
||||
@@ -115,58 +106,58 @@ func redeployWhenChanged(stack *portainer.Stack, deployer StackDeployer, datasto
|
||||
return nil
|
||||
}
|
||||
|
||||
registries, err := getUserRegistries(datastore, user, endpoint.ID)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(err)
|
||||
} else if err != nil {
|
||||
if err := deployStack(deployer, stack, endpoint, user, registries); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
|
||||
} else {
|
||||
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
|
||||
}
|
||||
|
||||
err = datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
latestStack, err := tx.Stack().Read(stack.ID)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
return err
|
||||
}
|
||||
case portainer.DockerSwarmStack:
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
|
||||
} else {
|
||||
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
case portainer.KubernetesStack:
|
||||
log.Debug().
|
||||
Int("stack_id", int(stackID)).
|
||||
Msg("deploying a kube app")
|
||||
|
||||
err := deployer.DeployKubernetesStack(stack, endpoint, user)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a kubernetes app stack %v", stackID)
|
||||
if gitCommitChangedOrForceUpdate {
|
||||
if latestStack.GitConfig != nil && stack.GitConfig != nil {
|
||||
latestStack.GitConfig.ConfigHash = stack.GitConfig.ConfigHash
|
||||
}
|
||||
|
||||
latestStack.UpdateDate = time.Now().Unix()
|
||||
}
|
||||
default:
|
||||
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
|
||||
}
|
||||
|
||||
stack.Status = portainer.StackStatusActive
|
||||
latestStack.Status = portainer.StackStatusActive
|
||||
|
||||
if err := datastore.Stack().Update(stack.ID, stack); err != nil {
|
||||
return tx.Stack().Update(stack.ID, latestStack)
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
|
||||
registries, err := datastore.Registry().ReadAll()
|
||||
func validateAuthor(tx dataservices.DataStoreTx, stack *portainer.Stack) (*portainer.User, error) {
|
||||
author := stack.UpdatedBy
|
||||
if author == "" {
|
||||
author = stack.CreatedBy
|
||||
}
|
||||
|
||||
user, err := tx.User().UserByUsername(author)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("author", author).
|
||||
Str("stack", stack.Name).
|
||||
Int("endpoint_id", int(stack.EndpointID)).
|
||||
Msg("cannot auto update a stack, stack author user is missing")
|
||||
|
||||
return nil, &StackAuthorMissingErr{int(stack.ID), author}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getUserRegistries(tx dataservices.DataStoreTx, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
|
||||
registries, err := tx.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "unable to retrieve registries from the database")
|
||||
}
|
||||
@@ -175,7 +166,7 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", user.Username)
|
||||
}
|
||||
@@ -208,3 +199,48 @@ func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
|
||||
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func deployStack(
|
||||
deployer StackDeployer,
|
||||
stack *portainer.Stack,
|
||||
endpoint *portainer.Endpoint,
|
||||
user *portainer.User,
|
||||
registries []portainer.Registry,
|
||||
) error {
|
||||
var err error
|
||||
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
|
||||
} else {
|
||||
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID)
|
||||
}
|
||||
case portainer.DockerSwarmStack:
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
|
||||
} else {
|
||||
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stack.ID)
|
||||
}
|
||||
case portainer.KubernetesStack:
|
||||
log.Debug().
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Msg("deploying a kube app")
|
||||
|
||||
err := deployer.DeployKubernetesStack(stack, endpoint, user)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a kubernetes app stack %v", stack.ID)
|
||||
}
|
||||
default:
|
||||
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
Str("cmd", strings.Join(cmd, " ")).
|
||||
Msg("running unpacker")
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
unpackerContainer, err := cli.ContainerCreate(ctx, &container.Config{
|
||||
Image: image,
|
||||
Cmd: cmd,
|
||||
|
||||
@@ -18,7 +18,7 @@ definitions:
|
||||
properties:
|
||||
jwt:
|
||||
description: JWT token used to authenticate against the API
|
||||
example: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB
|
||||
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
type: string
|
||||
type: object
|
||||
auth.oauthPayload:
|
||||
@@ -2524,7 +2524,7 @@ info:
|
||||
Example:
|
||||
|
||||
```
|
||||
Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzAB
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
1
app/__mocks__/axios-progress-bar.ts
Normal file
1
app/__mocks__/axios-progress-bar.ts
Normal file
@@ -0,0 +1 @@
|
||||
export function loadProgressBar() {}
|
||||
@@ -27,9 +27,10 @@ export function mockT(i18nKey: string, args?: Record<string, string>) {
|
||||
return key;
|
||||
}
|
||||
|
||||
export default {
|
||||
t: mockT,
|
||||
language: 'en',
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
use: () => this,
|
||||
};
|
||||
const i18next: Record<string, unknown> = jest.createMockFromModule('i18next');
|
||||
i18next.t = mockT;
|
||||
i18next.language = 'en';
|
||||
i18next.changeLanguage = () => new Promise(() => {});
|
||||
i18next.use = () => i18next;
|
||||
|
||||
export default i18next;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html[theme='dark'],
|
||||
|
||||
@@ -566,10 +566,6 @@
|
||||
--border-widget: var(--white-color);
|
||||
--border-stepper-color: var(--ui-gray-warm-9);
|
||||
|
||||
--button-close-color: var(--white-color);
|
||||
--button-opacity: 1;
|
||||
--button-opacity-hover: 0.7;
|
||||
|
||||
--shadow-box-color: none;
|
||||
--shadow-boxselector-color: none;
|
||||
|
||||
|
||||
@@ -238,12 +238,6 @@ textarea {
|
||||
background: var(--text-input-textarea);
|
||||
}
|
||||
|
||||
[theme='highcontrast'] input,
|
||||
[theme='highcontrast'] select,
|
||||
[theme='highcontrast'] textarea {
|
||||
border: 1px solid var(--white-color);
|
||||
}
|
||||
|
||||
.daterangepicker {
|
||||
background-color: var(--bg-daterangepicker-color);
|
||||
border: 1px solid var(--border-daterangepicker-color);
|
||||
@@ -355,26 +349,6 @@ input:-webkit-autofill {
|
||||
border-left: 8px solid var(--bg-tooltip-color);
|
||||
}
|
||||
|
||||
[theme='highcontrast'] .tippy-box[data-placement^='top'] > .tippy-arrow:before {
|
||||
border-top: 8px solid var(--white-color);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
[theme='highcontrast'] .tippy-box[data-placement^='bottom'] > .tippy-arrow:before {
|
||||
border-bottom: 8px solid var(--white-color);
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
[theme='highcontrast'] .tippy-box[data-placement^='right'] > .tippy-arrow:before {
|
||||
border-right: 8px solid var(--white-color);
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
[theme='highcontrast'] .tippy-box[data-placement^='left'] > .tippy-arrow:before {
|
||||
border-left: 8px solid var(--white-color);
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar .tippy-box {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -27,3 +27,6 @@ export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.';
|
||||
export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none'];
|
||||
export const PORTAINER_FADEOUT = 1500;
|
||||
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
|
||||
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
|
||||
export const KUBE_TEMPLATE_NAME_VALIDATION_REGEX =
|
||||
'^(([a-z0-9](?:(?:[-a-z0-9_.]){0,61}[a-z0-9])?))$'; // alphanumeric, lowercase, can contain dashes, dots and underscores, max 63 characters
|
||||
|
||||
@@ -126,7 +126,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createCustomTemplatesView',
|
||||
component: 'createCustomTemplateView',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -137,7 +137,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'editCustomTemplatesView',
|
||||
component: 'editCustomTemplateView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -151,10 +151,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
};
|
||||
|
||||
function resize(restcall, add) {
|
||||
if ($scope.state != states.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
add = add || 0;
|
||||
|
||||
term.fit();
|
||||
@@ -183,11 +179,8 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
socket.onopen = function () {
|
||||
$scope.state = states.connected;
|
||||
term = new Terminal();
|
||||
socket.send('export LANG=C.UTF-8\n');
|
||||
socket.send('export LC_ALL=C.UTF-8\n');
|
||||
socket.send('clear\n');
|
||||
|
||||
term.onData(function (data) {
|
||||
term.on('data', function (data) {
|
||||
socket.send(data);
|
||||
});
|
||||
var terminal_container = document.getElementById('terminal-container');
|
||||
|
||||
@@ -69,11 +69,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
|
||||
<terminal-tooltip> </terminal-tooltip>
|
||||
</label>
|
||||
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code></label
|
||||
>
|
||||
<button type="button" class="btn btn-primary" ng-click="disconnect()">
|
||||
<span ng-show="state === states.connected">Disconnect</span>
|
||||
<span ng-show="state === states.connecting">Connecting...</span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<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
|
||||
<a href="https://docs.portainer.io/admin/environments/add/swarm/agent" target="_blank">our agent setup</a> for more details.
|
||||
<a href="https://docs.portainer.io/start/install/agent/swarm/linux" target="_blank">our agent setup</a> for more details.
|
||||
</p>
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
@@ -102,7 +102,7 @@
|
||||
</a>
|
||||
|
||||
<a class="no-link" ui-sref="docker.networks">
|
||||
<dashboard-item icon="'Network'" type="'Network'" value="networkCount"></dashboard-item>
|
||||
<dashboard-item icon="'share2'" type="'Network'" value="networkCount"></dashboard-item>
|
||||
</a>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label="'Show image up to date indicators for Stacks, Services and Containers'"
|
||||
label="'Show an image(s) up to date indicator for Stacks, Services and Containers'"
|
||||
checked="false"
|
||||
name="'outOfDateImageToggle'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
|
||||
@@ -62,7 +62,7 @@ angular
|
||||
|
||||
const stacksNew = {
|
||||
name: 'edge.stacks.new',
|
||||
url: '/new?templateId&templateType',
|
||||
url: '/new?templateId',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createEdgeStackView',
|
||||
@@ -174,7 +174,7 @@ angular
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createCustomTemplatesView',
|
||||
component: 'edgeCreateCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -185,7 +185,7 @@ angular
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'editCustomTemplatesView',
|
||||
component: 'edgeEditCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,9 +13,9 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
|
||||
const ngModule = angular
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
.component(
|
||||
'edgeStackEnvironmentsDatatable',
|
||||
@@ -104,6 +104,4 @@ const ngModule = angular
|
||||
.component(
|
||||
'edgeStackCreateTemplateFieldset',
|
||||
r2a(withReactQuery(TemplateFieldset), ['setValues', 'values', 'errors'])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
).name;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
|
||||
import { CreateView } from '@/react/edge/templates/custom-templates/CreateView';
|
||||
import { EditView } from '@/react/edge/templates/custom-templates/EditView';
|
||||
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
|
||||
import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
|
||||
import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
|
||||
|
||||
export const templatesModule = angular
|
||||
.module('portainer.app.react.components.templates', [])
|
||||
@@ -19,10 +19,10 @@ export const templatesModule = angular
|
||||
r2a(withCurrentUser(withUIRouter(ListView)), [])
|
||||
)
|
||||
.component(
|
||||
'createCustomTemplatesView',
|
||||
'edgeCreateCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||
)
|
||||
.component(
|
||||
'editCustomTemplatesView',
|
||||
'edgeEditCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(EditView)), [])
|
||||
).name;
|
||||
|
||||
@@ -13,11 +13,7 @@ import { StackType } from '@/react/common/stacks/types';
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
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';
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
|
||||
export default class CreateEdgeStackViewController {
|
||||
/* @ngInject */
|
||||
@@ -77,7 +73,7 @@ export default class CreateEdgeStackViewController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
|
||||
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction
|
||||
*/
|
||||
setTemplateValues(templateAction) {
|
||||
return this.$async(async () => {
|
||||
@@ -86,52 +82,41 @@ export default class CreateEdgeStackViewController {
|
||||
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
|
||||
this.state.templateValues = newTemplateValues;
|
||||
if (newTemplateId !== oldTemplateId) {
|
||||
await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
|
||||
await this.onChangeTemplate(newTemplateValues.template);
|
||||
}
|
||||
|
||||
if (newTemplateValues.type === 'custom') {
|
||||
const 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, this.state.templateValues.template.Variables);
|
||||
|
||||
this.formValues.StackFileContent = newFile;
|
||||
}
|
||||
this.formValues.StackFileContent = newFile;
|
||||
});
|
||||
}
|
||||
|
||||
onChangeTemplate(type, template) {
|
||||
onChangeTemplate(template) {
|
||||
return this.$async(async () => {
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'custom') {
|
||||
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
|
||||
this.state.templateValues.file = fileContent;
|
||||
this.state.templateValues.template = template;
|
||||
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 });
|
||||
this.state.templateValues.file = fileContent;
|
||||
|
||||
if (type === 'app') {
|
||||
this.formValues.StackFileContent = '';
|
||||
try {
|
||||
const fileContent = await fetchFilePreview(template.Id);
|
||||
this.formValues.StackFileContent = fileContent;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
||||
}
|
||||
}
|
||||
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,
|
||||
SupportRelativePath: template.EdgeSettings.RelativePathSettings.SupportRelativePath || false,
|
||||
FilesystemPath: template.EdgeSettings.RelativePathSettings.FilesystemPath || '',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,27 +156,13 @@ export default class CreateEdgeStackViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'app' | 'custom'} templateType
|
||||
* @param {number} templateId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async preSelectTemplate(templateType, templateId) {
|
||||
async preSelectTemplate(templateId) {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.Method = 'template';
|
||||
const template = await getTemplate(templateType, templateId);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const template = await getCustomTemplate(templateId);
|
||||
|
||||
this.setTemplateValues({
|
||||
template,
|
||||
type: templateType,
|
||||
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
|
||||
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
|
||||
});
|
||||
this.setTemplateValues({ template });
|
||||
} catch (e) {
|
||||
notifyError('Failed loading template', e);
|
||||
}
|
||||
@@ -205,10 +176,9 @@ export default class CreateEdgeStackViewController {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||
}
|
||||
|
||||
const templateId = parseInt(this.$state.params.templateId, 10);
|
||||
const templateType = this.$state.params.templateType;
|
||||
if (templateType && templateId && !Number.isNaN(templateId)) {
|
||||
this.preSelectTemplate(templateType, templateId);
|
||||
const templateId = this.$state.params.templateId;
|
||||
if (templateId) {
|
||||
this.preSelectTemplate(templateId);
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
@@ -225,21 +195,19 @@ export default class CreateEdgeStackViewController {
|
||||
createStack() {
|
||||
return this.$async(async () => {
|
||||
const name = this.formValues.Name;
|
||||
let method = this.state.Method;
|
||||
|
||||
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 }))];
|
||||
if (method === 'template') {
|
||||
method = 'editor';
|
||||
}
|
||||
|
||||
const method = getMethod(this.state.Method, this.state.templateValues.template);
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
await this.createStackByMethod(name, method, envVars);
|
||||
await this.createStackByMethod(name, method);
|
||||
|
||||
this.Notifications.success('Success', 'Stack successfully deployed');
|
||||
this.state.isEditorDirty = false;
|
||||
@@ -291,19 +259,19 @@ export default class CreateEdgeStackViewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
createStackByMethod(name, method, envVars) {
|
||||
createStackByMethod(name, method) {
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createStackFromFileContent(name, envVars);
|
||||
return this.createStackFromFileContent(name);
|
||||
case 'upload':
|
||||
return this.createStackFromFileUpload(name, envVars);
|
||||
return this.createStackFromFileUpload(name);
|
||||
case 'repository':
|
||||
return this.createStackFromGitRepository(name, envVars);
|
||||
return this.createStackFromGitRepository(name);
|
||||
}
|
||||
}
|
||||
|
||||
createStackFromFileContent(name, envVars) {
|
||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
createStackFromFileContent(name) {
|
||||
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
|
||||
return this.EdgeStackService.createStackFromFileContent({
|
||||
name,
|
||||
@@ -315,9 +283,8 @@ export default class CreateEdgeStackViewController {
|
||||
});
|
||||
}
|
||||
|
||||
createStackFromFileUpload(name, envVars) {
|
||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
|
||||
createStackFromFileUpload(name) {
|
||||
const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
return this.EdgeStackService.createStackFromFileUpload(
|
||||
{
|
||||
Name: name,
|
||||
@@ -330,9 +297,8 @@ export default class CreateEdgeStackViewController {
|
||||
);
|
||||
}
|
||||
|
||||
async createStackFromGitRepository(name, envVars) {
|
||||
const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
|
||||
|
||||
createStackFromGitRepository(name) {
|
||||
const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
|
||||
const repositoryOptions = {
|
||||
RepositoryURL: this.formValues.RepositoryURL,
|
||||
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
||||
@@ -372,42 +338,3 @@ export default class CreateEdgeStackViewController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'template'|'repository' | 'editor' | 'upload'} method
|
||||
* @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined} template
|
||||
* @returns 'repository' | 'editor' | 'upload'
|
||||
*/
|
||||
function getMethod(method, template) {
|
||||
if (method !== 'template') {
|
||||
return method;
|
||||
}
|
||||
|
||||
if (template && template.GitConfig) {
|
||||
return 'repository';
|
||||
}
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {'app' | 'custom'} templateType
|
||||
* @param {number} templateId
|
||||
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
|
||||
*/
|
||||
async function getTemplate(templateType, templateId) {
|
||||
if (!['app', 'custom'].includes(templateType)) {
|
||||
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateType === 'app') {
|
||||
const templatesResponse = await getAppTemplates();
|
||||
const template = templatesResponse.templates.find((t) => t.id === templateId);
|
||||
return new TemplateViewModel(template, templatesResponse.version);
|
||||
}
|
||||
|
||||
const template = await getCustomTemplate(templateId);
|
||||
return template;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
|
||||
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
class DockerComposeFormController {
|
||||
@@ -12,11 +12,6 @@ class DockerComposeFormController {
|
||||
this.onChangeFile = this.onChangeFile.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
||||
this.isGitTemplate = this.isGitTemplate.bind(this);
|
||||
}
|
||||
|
||||
isGitTemplate() {
|
||||
return this.state.Method === 'template' && !!this.templateValues.template && !!this.templateValues.template.GitConfig;
|
||||
}
|
||||
|
||||
onChangeFormValues(newValues) {
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
versions="$ctrl.formValues.versions"
|
||||
read-only="$ctrl.isGitTemplate()"
|
||||
read-only="$ctrl.state.Method === 'template' && $ctrl.template.GitConfig"
|
||||
>
|
||||
<editor-description>
|
||||
You can get more information about Compose file format in the
|
||||
@@ -29,7 +28,7 @@
|
||||
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
|
||||
</file-upload-form>
|
||||
|
||||
<div ng-if="$ctrl.state.Method == 'repository' || $ctrl.isGitTemplate()">
|
||||
<div ng-if="$ctrl.state.Method == 'repository'">
|
||||
<git-form
|
||||
value="$ctrl.formValues"
|
||||
on-change="($ctrl.onChangeFormValues)"
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-fit">
|
||||
<helm-insights-box></helm-insights-box>
|
||||
<insights-box type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@
|
||||
<div class="menuContent">
|
||||
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
|
||||
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
|
||||
<label for="filter_state_{{ $index }}">{{ filter.type }}</label>
|
||||
<label for="filter_state_{{ $index }}">{{ filter.type | kubernetesApplicationTypeText }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -282,7 +282,7 @@
|
||||
</a>
|
||||
<a
|
||||
ng-if="!item.KubernetesApplications"
|
||||
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': item.ApplicationType })"
|
||||
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': $ctrl.applicationTypeEnumToParamMap[item.ApplicationType] })"
|
||||
ng-click="$event.stopPropagation()"
|
||||
class="hyperlink"
|
||||
>{{ item.Name }}
|
||||
@@ -297,17 +297,17 @@
|
||||
<td title="{{ item.Image }}"
|
||||
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
>
|
||||
<td>{{ item.ApplicationType }}</td>
|
||||
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.Pod">
|
||||
<td>{{ item.ApplicationType | kubernetesApplicationTypeText }}</td>
|
||||
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD">
|
||||
<status-indicator ok="(item.TotalPodsCount > 0 && item.TotalPodsCount === item.RunningPodsCount) || item.Status === 'Ready'"></status-indicator>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Replicated">Replicated</span>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Global">Global</span>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.REPLICATED">Replicated</span>
|
||||
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.GLOBAL">Global</span>
|
||||
<span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0"
|
||||
><code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code></span
|
||||
>
|
||||
<span ng-if="item.KubernetesApplications">{{ item.Status }}</span>
|
||||
</td>
|
||||
<td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.Pod">
|
||||
<td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.POD">
|
||||
{{ item.Pods[0].Status }}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user