Compare commits

..

3 Commits

Author SHA1 Message Date
testa113
17971a10cc address review comments 2024-01-03 16:03:51 +13:00
testa113
218a32a49f po feedback 2024-01-03 16:03:51 +13:00
testa113
2cf7ef90bf refactor(app): redeploy git form [EE-6444] 2024-01-03 16:03:50 +13:00
2107 changed files with 28298 additions and 43101 deletions

View File

@@ -10,7 +10,6 @@ globals:
extends:
- 'eslint:recommended'
- 'plugin:storybook/recommended'
- 'plugin:import/typescript'
- prettier
plugins:
@@ -30,7 +29,6 @@ rules:
no-empty: warn
no-empty-function: warn
no-useless-escape: 'off'
import/named: error
import/order:
[
'error',
@@ -45,12 +43,6 @@ rules:
pathGroupsExcludedImportTypes: ['internal'],
},
]
no-restricted-imports:
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
settings:
'import/resolver':
@@ -59,8 +51,6 @@ settings:
- ['@@', './app/react/components']
- ['@', './app']
extensions: ['.js', '.ts', '.tsx']
typescript: true
node: true
overrides:
- files:
@@ -85,9 +75,7 @@ overrides:
settings:
react:
version: 'detect'
rules:
no-console: error
import/order:
[
'error',
@@ -120,12 +108,6 @@ overrides:
'no-await-in-loop': 'off'
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
'@typescript-eslint/no-restricted-imports':
- error
- patterns:
- group:
- '@/react/test-utils/*'
message: 'These utils are just for test files'
overrides: # allow props spreading for hoc files
- files:
- app/**/with*.ts{,x}
@@ -134,18 +116,13 @@ 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
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
- files:
- app/**/*.stories.*
rules:
'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off

View File

@@ -93,11 +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.20.3'
- '2.20.2'
- '2.20.1'
- '2.20.0'
- '2.19.5'
- '2.19.4'
- '2.19.3'
- '2.19.2'

View File

@@ -5,7 +5,7 @@ on:
push:
branches:
- 'develop'
- 'release/*'
- '!release/*'
pull_request:
branches:
- 'develop'
@@ -20,9 +20,9 @@ on:
- ready_for_review
env:
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.9
DOCKER_HUB_REPO: portainerci/portainer
NODE_ENV: testing
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
jobs:
@@ -30,72 +30,86 @@ 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
runs-on: arc-runner-set
if: github.event.pull_request.draft == false
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 +121,35 @@ 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
runs-on: arc-runner-set
if: github.event.pull_request.draft == false
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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,18 @@
name: Test
env:
GO_VERSION: 1.21.9
GO_VERSION: 1.21.5
NODE_VERSION: 18.x
on:
pull_request:
branches:
- master
- develop
- release/*
types:
- opened
- reopened
- synchronize
- ready_for_review
push:
branches:
- master
- develop
- release/*
jobs:
test-client:
runs-on: ubuntu-latest
@@ -35,7 +27,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:

View File

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

View File

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

View File

@@ -1,25 +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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
initMSW(
{
onUnhandledRequest: ({ method, url }) => {
if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
import { handlers } from '@/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from 'react-query';
// 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].*' },
@@ -46,6 +44,5 @@ export const decorators = [
</UIRouter>
</QueryClientProvider>
),
mswDecorator,
];
export const loaders = [mswLoader];

View File

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

View File

@@ -9,7 +9,7 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
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

View File

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

View File

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

View File

@@ -17,25 +17,25 @@ 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,
},
}
for _, test := range tests {
t.Run(test.digest, func(t *testing.T) {
t.Run(string(test.digest), func(t *testing.T) {
_, _, found := keyCache.Get(test.digest)
is.Equal(test.found, found)
})
@@ -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)
}
})

View File

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

View File

@@ -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[:])
})
}

View File

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

View File

@@ -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,9 +82,14 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
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) {

View File

@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
if password != "" {
archive, err = decrypt(archive, password)
if err != nil {
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
return errors.Wrap(err, "failed to decrypt the archive")
}
}

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,20 @@
package chisel
import (
"context"
"net"
"net/http"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestPingAgentPanic(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: 1,
Type: portainer.EdgeAgentOnDockerEnvironment,
}
endpointID := portainer.EndpointID(1)
_, store := datastore.MustNewTestStore(t, true, true)
s := NewService(store, nil, nil)
s := NewService(nil, nil, nil)
defer func() {
require.Nil(t, recover())
@@ -35,17 +28,12 @@ func TestPingAgentPanic(t *testing.T) {
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
require.NoError(t, err)
srv := &http.Server{Handler: mux}
errCh := make(chan error)
go func() {
errCh <- srv.Serve(ln)
require.NoError(t, http.Serve(ln, mux))
}()
s.Open(endpoint)
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpoint.ID))
require.NoError(t, srv.Shutdown(context.Background()))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
require.Error(t, s.pingAgent(endpointID))
}

View File

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

View File

@@ -34,6 +34,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
@@ -61,7 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
}
kingpin.Parse()

View File

@@ -42,13 +42,6 @@ func setLoggingMode(mode string) {
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
})
case "NOCOLOR":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
NoColor: true,
})
case "JSON":
log.Logger = log.Output(os.Stderr)
}

View File

@@ -19,7 +19,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/datastore/postinit"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/exec"
@@ -42,8 +42,6 @@ import (
"github.com/portainer/portainer/api/ldap"
"github.com/portainer/portainer/api/oauth"
"github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/api/pendingactions/handlers"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/featureflags"
@@ -459,11 +457,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
authorizationService := authorization.NewService(dataStore)
authorizationService.K8sClientFactory = kubernetesClientFactory
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
proxyManager := proxy.NewManager(kubernetesClientFactory)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
reverseTunnelService.ProxyManager = proxyManager
@@ -483,19 +489,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
proxyManager.NewProxyFactory(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
@@ -508,6 +501,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
applicationStatus := initStatus(instanceID)
demoService := demo.NewService()
if *flags.DemoEnvironment {
err := demoService.Init(dataStore, cryptoService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing demo environment")
}
}
// channel to control when the admin user is created
adminCreationDone := make(chan struct{}, 1)
@@ -577,12 +578,10 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
// but some more complex migrations require access to a kubernetes or docker
// client. Therefore we run a separate migration process just before
// starting the server.
postInitMigrator := postinit.NewPostInitMigrator(
postInitMigrator := datastore.NewPostInitMigrator(
kubernetesClientFactory,
dockerClientFactory,
dataStore,
*flags.Assets,
kubernetesDeployer,
)
if err := postInitMigrator.PostInitMigrate(); err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations")
@@ -622,6 +621,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
DemoService: demoService,
UpgradeService: upgradeService,
AdminCreationDone: adminCreationDone,
PendingActionsService: pendingActionsService,
@@ -650,7 +650,6 @@ func main() {
Msg("starting Portainer")
err := server.Start()
log.Info().Err(err).Msg("HTTP server exited")
}
}

View File

@@ -1,216 +1,52 @@
package crypto
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/scrypt"
)
const (
// AES GCM settings
aesGcmHeader = "AES256-GCM" // The encrypted file header
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
// NOTE: has to go with what is considered to be a simplistic in that it omits any
// authentication of the encrypted data.
// Person with better knowledge is welcomed to improve it.
// sourced from https://golang.org/src/crypto/cipher/example_test.go
// Argon2 settings
// Recommded settings lower memory hardware according to current OWASP recommendations
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
argon2MemoryCost = 12 * 1024
argon2TimeCost = 3
argon2Threads = 1
argon2KeyLength = 32
)
var emptySalt []byte = make([]byte, 0)
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
err := aesEncryptGCM(input, output, passphrase)
if err != nil {
return fmt.Errorf("error encrypting file: %w", err)
}
return nil
}
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// Read file header to determine how it was encrypted
inputReader := bufio.NewReader(input)
header, err := inputReader.Peek(len(aesGcmHeader))
if err != nil {
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
}
if string(header) == aesGcmHeader {
reader, err := aesDecryptGCM(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting file: %w", err)
}
return reader, nil
}
// Use the previous decryption routine which has no header (to support older archives)
reader, err := aesDecryptOFB(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
}
return reader, nil
}
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
// Derive key using argon2 with a random salt
salt := make([]byte, 16) // 16 bytes salt
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
// Generate nonce
nonce, err := NewRandomNonce(aesgcm.NonceSize())
if err != nil {
return err
}
// write the header
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
return err
}
// Write nonce and salt to the output file
if _, err := output.Write(salt); err != nil {
return err
}
if _, err := output.Write(nonce.Value()); err != nil {
return err
}
// Buffer for reading plaintext blocks
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
// Encrypt plaintext in blocks
for {
n, err := io.ReadFull(input, buf)
if n == 0 {
break // end of plaintext input
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return err
}
// Seal encrypts the plaintext using the nonce returning the updated slice.
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
_, err = output.Write(ciphertext)
if err != nil {
return err
}
nonce.Increment()
}
return nil
}
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
// Reader & verify header
header := make([]byte, len(aesGcmHeader))
if _, err := io.ReadFull(input, header); err != nil {
return nil, err
}
if string(header) != aesGcmHeader {
return nil, fmt.Errorf("invalid header")
}
// Read salt
salt := make([]byte, 16) // Salt size
if _, err := io.ReadFull(input, salt); err != nil {
return nil, err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
// Initialize AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create GCM mode with the cipher block
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Read nonce from the input reader
nonce := NewNonce(aesgcm.NonceSize())
if err := nonce.Read(input); err != nil {
return nil, err
}
// Initialize a buffer to store decrypted data
buf := bytes.Buffer{}
plaintext := make([]byte, aesGcmBlockSize)
// Decrypt the ciphertext in blocks
for {
// Read a block of ciphertext from the input reader
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
n, err := io.ReadFull(input, ciphertextBlock)
if n == 0 {
break // end of ciphertext
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return nil, err
}
// Decrypt the block of ciphertext
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
if err != nil {
return nil, err
}
_, err = buf.Write(plaintext)
if err != nil {
return nil, err
}
nonce.Increment()
}
return &buf, nil
}
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
// passphrase is used to generate an encryption key.
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
var emptySalt []byte = make([]byte, 0)
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
writer := &cipher.StreamWriter{S: stream, W: output}
// Copy the input to the output, encrypting as we go.
if _, err := io.Copy(writer, input); err != nil {
return err
}
return nil
}
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// passphrase is used to generate an encryption key.
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
@@ -223,9 +59,11 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
reader := &cipher.StreamReader{S: stream, R: input}
return reader, nil

View File

@@ -2,7 +2,6 @@ package crypto
import (
"io"
"math/rand"
"os"
"path/filepath"
"testing"
@@ -10,19 +9,7 @@ import (
"github.com/stretchr/testify/assert"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return b
}
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
const passphrase = "passphrase"
tmpdir := t.TempDir()
var (
@@ -31,99 +18,17 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
@@ -152,7 +57,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1024 * 50)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -191,7 +96,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1034)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -212,6 +117,11 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
}

View File

@@ -1,61 +0,0 @@
package crypto
import (
"crypto/rand"
"errors"
"io"
)
type Nonce struct {
val []byte
}
func NewNonce(size int) *Nonce {
return &Nonce{val: make([]byte, size)}
}
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
// This ensures there are plenty of nonce values availble before rolling over
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
func NewRandomNonce(size int) (*Nonce, error) {
randomBytes := 1
if size <= randomBytes {
return nil, errors.New("nonce size must be greater than the number of random bytes")
}
randomPart := make([]byte, randomBytes)
if _, err := rand.Read(randomPart); err != nil {
return nil, err
}
zeroPart := make([]byte, size-randomBytes)
nonceVal := append(randomPart, zeroPart...)
return &Nonce{val: nonceVal}, nil
}
func (n *Nonce) Read(stream io.Reader) error {
_, err := io.ReadFull(stream, n.val)
return err
}
func (n *Nonce) Value() []byte {
return n.val
}
func (n *Nonce) Increment() error {
// Start incrementing from the least significant byte
for i := len(n.val) - 1; i >= 0; i-- {
// Increment the current byte
n.val[i]++
// Check for overflow
if n.val[i] != 0 {
// No overflow, nonce is successfully incremented
return nil
}
}
// If we reach here, it means the nonce has overflowed
return errors.New("nonce overflow")
}

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
package dataservices
import (
"io"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
)
@@ -36,7 +38,6 @@ type (
}
DataStore interface {
Connection() portainer.Connection
Open() (newStore bool, err error)
Init() error
Close() error
@@ -45,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
@@ -72,9 +73,8 @@ type (
}
PendingActionsService interface {
BaseCRUD[portainer.PendingAction, portainer.PendingActionID]
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
GetNextIdentifier() int
DeleteByEndpointID(ID portainer.EndpointID) error
}
// EdgeStackService represents a service to manage Edge stacks
@@ -152,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

View File

@@ -1,12 +1,10 @@
package pendingactions
import (
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const (
@@ -14,11 +12,11 @@ const (
)
type Service struct {
dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]
dataservices.BaseDataService[portainer.PendingActions, portainer.PendingActionsID]
}
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]
}
func NewService(connection portainer.Connection) (*Service, error) {
@@ -28,34 +26,28 @@ func NewService(connection portainer.Connection) (*Service, error) {
}
return &Service{
BaseDataService: dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]{
BaseDataService: dataservices.BaseDataService[portainer.PendingActions, portainer.PendingActionsID]{
Bucket: BucketName,
Connection: connection,
},
}, nil
}
func (s Service) Create(config *portainer.PendingAction) error {
func (s Service) Create(config *portainer.PendingActions) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Create(config)
})
}
func (s Service) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
func (s Service) Update(ID portainer.PendingActionsID, config *portainer.PendingActions) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Update(ID, config)
})
}
func (s Service) DeleteByEndpointID(ID portainer.EndpointID) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).DeleteByEndpointID(ID)
})
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
@@ -63,42 +55,19 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
}
}
func (s ServiceTx) Create(config *portainer.PendingAction) error {
func (s ServiceTx) Create(config *portainer.PendingActions) error {
return s.Tx.CreateObject(BucketName, func(id uint64) (int, interface{}) {
config.ID = portainer.PendingActionID(id)
config.ID = portainer.PendingActionsID(id)
config.CreatedAt = time.Now().Unix()
return int(config.ID), config
})
}
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
func (s ServiceTx) Update(ID portainer.PendingActionsID, config *portainer.PendingActions) error {
return s.BaseDataServiceTx.Update(ID, config)
}
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
pendingActions, err := s.BaseDataServiceTx.ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
}
for _, pendingAction := range pendingActions {
if pendingAction.EndpointID == ID {
err := s.BaseDataServiceTx.Delete(pendingAction.ID)
if err != nil {
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
}
}
}
return nil
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service ServiceTx) GetNextIdentifier() int {
return service.Tx.GetNextIdentifier(BucketName)
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName)

View File

@@ -9,19 +9,12 @@ import (
"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) {
func (store *Store) Backup() (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
@@ -76,7 +69,7 @@ func (store *Store) RestoreFromFile(backupFilename string) error {
func (store *Store) createBackupPath() error {
backupDir := path.Join(store.connection.GetStorePath(), "backups")
if exists, _ := store.fileService.FileExists(backupDir); !exists {
if err := os.MkdirAll(backupDir, 0o700); err != nil {
if err := os.MkdirAll(backupDir, 0700); err != nil {
return fmt.Errorf("unable to create backup folder: %w", err)
}
}

View File

@@ -39,7 +39,7 @@ func TestBackup(t *testing.T) {
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.Backup("")
store.Backup()
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
@@ -55,7 +55,7 @@ func TestRestore(t *testing.T) {
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
store.Backup()
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
@@ -68,7 +68,7 @@ func TestRestore(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup("")
store.Backup()
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)

View File

@@ -31,7 +31,7 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup("")
backupFilename, err := store.Backup()
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}

View File

@@ -40,7 +40,7 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
_, err = store.Backup("")
_, err = store.Backup()
if err != nil {
return errors.Wrap(err, "while backing up database")
}
@@ -86,7 +86,6 @@ func (store *Store) newMigratorParameters(version *models.Version) *migrator.Mig
EdgeStackService: store.EdgeStackService,
EdgeJobService: store.EdgeJobService,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
}
}
@@ -132,6 +131,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 {

View File

@@ -165,7 +165,7 @@ func TestRollback(t *testing.T) {
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
_, err := store.Backup()
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -199,7 +199,7 @@ func TestRollback(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup("")
_, err := store.Backup()
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -305,7 +305,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
os.WriteFile(
gotPath,
gotJSON,
0o600,
0600,
)
t.Errorf(
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",

View File

@@ -99,7 +99,7 @@ func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
return &models.Version{
SchemaVersion: dbVersionToSemanticVersion(dbVersion),
Edition: edition,
InstanceID: instanceId,
InstanceID: string(instanceId),
}, nil
}

View File

@@ -0,0 +1,117 @@
package datastore
import (
"context"
"github.com/docker/docker/api/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerclient.ClientFactory
dataStore dataservices.DataStore
}
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
}
}
func (migrator *PostInitMigrator) PostInitMigrate() error {
if err := migrator.PostInitMigrateIngresses(); err != nil {
return err
}
migrator.PostInitMigrateGPUs()
return nil
}
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if !endpoints[i].PostInitMigrations.MigrateIngresses {
return nil
}
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
environments, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Err(err).Msg("failure getting endpoints")
return
}
for i := range environments {
if environments[i].Type == portainer.DockerEnvironment {
// // Early exit if we do not need to migrate!
if !environments[i].PostInitMigrations.MigrateGPUs {
return
}
// set the MigrateGPUs flag to false so we don't run this again
environments[i].PostInitMigrations.MigrateGPUs = false
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
// create a docker client
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
if err != nil {
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
return
}
defer dockerClient.Close()
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
log.Err(err).Msg("failed to list containers")
return
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Err(err).Msg("failed to inspect container")
return
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environments[i].EnableGPUManagement = true
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
break containersLoop
}
}
}
}
}
}

View File

@@ -23,29 +23,3 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
return migrator.settingsService.UpdateSettings(settings)
}
// In PortainerCE the resource overcommit option should always be true across all endpoints
func (migrator *Migrator) updateResourceOverCommitToDB110() error {
log.Info().Msg("updating resource overcommit setting to true")
endpoints, err := migrator.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.KubernetesLocalEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true
err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -1,32 +0,0 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
func (migrator *Migrator) cleanPendingActionsForDeletedEndpointsForDB111() error {
log.Info().Msg("cleaning up pending actions for deleted endpoints")
pendingActions, err := migrator.pendingActionsService.ReadAll()
if err != nil {
return err
}
endpoints := make(map[portainer.EndpointID]struct{})
for _, action := range pendingActions {
endpoints[action.EndpointID] = struct{}{}
}
for endpointId := range endpoints {
_, err := migrator.endpointService.Endpoint(endpointId)
if dataservices.IsErrObjectNotFound(err) {
err := migrator.pendingActionsService.DeleteByEndpointID(endpointId)
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -1,33 +0,0 @@
package migrator
import (
"github.com/segmentio/encoding/json"
"github.com/rs/zerolog/log"
)
func (migrator *Migrator) migratePendingActionsDataForDB130() error {
log.Info().Msg("Migrating pending actions data")
pendingActions, err := migrator.pendingActionsService.ReadAll()
if err != nil {
return err
}
for _, pa := range pendingActions {
actionData, err := json.Marshal(pa.ActionData)
if err != nil {
return err
}
pa.ActionData = string(actionData)
// Update the pending action
err = migrator.pendingActionsService.Update(pa.ID, &pa)
if err != nil {
return err
}
}
return nil
}

View File

@@ -123,7 +123,7 @@ func (m *Migrator) updateDockerhubToDB32() error {
migrated = true
} else {
// delete subsequent duplicates
m.registryService.Delete(r.ID)
m.registryService.Delete(portainer.RegistryID(r.ID))
}
}
}

View File

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

View File

@@ -1,98 +0,0 @@
package datastore
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/portainer/portainer/api/pendingactions/handlers"
)
type cleanNAPWithOverridePolicies struct {
EndpointGroupID portainer.EndpointGroupID
}
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
defer store.Close()
gid := portainer.EndpointGroupID(1)
testData := []struct {
Name string
PendingAction portainer.PendingAction
Expected any
Err bool
}{
{
Name: "test actiondata with EndpointGroupID 1",
PendingAction: handlers.NewCleanNAPWithOverridePolicies(
1,
&gid,
),
Expected: portainer.EndpointGroupID(1),
},
{
Name: "test actionData nil",
PendingAction: handlers.NewCleanNAPWithOverridePolicies(
2,
nil,
),
Expected: nil,
},
{
Name: "test actionData empty and expected error",
PendingAction: portainer.PendingAction{
EndpointID: 2,
Action: actions.CleanNAPWithOverridePolicies,
ActionData: "",
},
Expected: nil,
Err: true,
},
}
for _, d := range testData {
err := store.PendingActions().Create(&d.PendingAction)
if err != nil {
t.Error(err)
return
}
pendingActions, err := store.PendingActions().ReadAll()
if err != nil {
t.Error(err)
return
}
for _, endpointPendingAction := range pendingActions {
t.Run(d.Name, func(t *testing.T) {
if endpointPendingAction.Action == actions.CleanNAPWithOverridePolicies {
var payload cleanNAPWithOverridePolicies
err := endpointPendingAction.UnmarshallActionData(&payload)
if d.Err && err == nil {
t.Error(err)
}
if d.Expected == nil && payload.EndpointGroupID != 0 {
t.Errorf("expected nil, got %d", payload.EndpointGroupID)
}
if d.Expected != nil {
expected := d.Expected.(portainer.EndpointGroupID)
if d.Expected != nil && expected != payload.EndpointGroupID {
t.Errorf("expected EndpointGroupID %d, got %d", expected, payload.EndpointGroupID)
}
}
}
})
}
store.PendingActions().Delete(d.PendingAction.ID)
}
})
}

View File

@@ -1,184 +0,0 @@
package postinit
import (
"context"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerClient.ClientFactory
dataStore dataservices.DataStore
assetsPath string
kubernetesDeployer portainer.KubernetesDeployer
}
func NewPostInitMigrator(
kubeFactory *cli.ClientFactory,
dockerFactory *dockerClient.ClientFactory,
dataStore dataservices.DataStore,
assetsPath string,
kubernetesDeployer portainer.KubernetesDeployer,
) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
assetsPath: assetsPath,
kubernetesDeployer: kubernetesDeployer,
}
}
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Error().Err(err).Msg("Error getting environments")
return err
}
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpointutils.IsEdgeEndpoint(&environment) {
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
}
} else {
// non-edge environments will run before the server starts.
err = postInitMigrator.MigrateEnvironment(&environment)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
}
}
}
return nil
}
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
// TODO: This should be moved into pending actions as part of the pending action migration
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
// If there are no pending actions for the given endpoint, create one
err := postInitMigrator.dataStore.PendingActions().Create(&portainer.PendingAction{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
})
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
}
return nil
}
// MigrateEnvironment runs migrations on a single environment
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
switch {
case endpointutils.IsKubernetesEndpoint(environment):
// get the kubeclient for the environment, and skip all kube migrations if there's an error
kubeclient, err := migrator.kubeFactory.GetKubeClient(environment)
if err != nil {
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
return err
}
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
err = migrator.MigrateIngresses(*environment, kubeclient)
if err != nil {
return err
}
return nil
case endpointutils.IsDockerEndpoint(environment):
// get the docker client for the environment, and skip all docker migrations if there's an error
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
if err != nil {
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
return err
}
defer dockerClient.Close()
migrator.MigrateGPUs(*environment, dockerClient)
}
return nil
}
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateIngresses {
return nil
}
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
if err != nil {
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
return err
}
return nil
}
// MigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient *client.Client) error {
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
environment, err := tx.Endpoint().Endpoint(e.ID)
if err != nil {
log.Error().Err(err).Msgf("Error getting environment %d", environment.ID)
return err
}
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateGPUs {
return nil
}
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
return err
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Error().Err(err).Msg("failed to inspect container")
continue
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environment.EnableGPUManagement = true
break containersLoop
}
}
}
// set the MigrateGPUs flag to false so we don't run this again
environment.PostInitMigrations.MigrateGPUs = false
err = tx.Endpoint().UpdateEndpoint(environment.ID, environment)
if err != nil {
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
return err
}
return nil
})
}

View File

@@ -616,7 +616,7 @@ func (store *Store) Import(filename string) (err error) {
if err != nil {
return err
}
err = json.Unmarshal(s, &backup)
err = json.Unmarshal([]byte(s), &backup)
if err != nil {
return err
}

View File

@@ -16,9 +16,7 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil }
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService {
return tx.store.PendingActionsService.Tx(tx.tx)
}
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService { return nil }
func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService {
return tx.store.EdgeGroupService.Tx(tx.tx)
@@ -70,10 +68,7 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
func (tx *StoreTx) Stack() dataservices.StackService {
return tx.store.StackService.Tx(tx.tx)
}
func (tx *StoreTx) Stack() dataservices.StackService { return nil }
func (tx *StoreTx) Tag() dataservices.TagService {
return tx.store.TagService.Tx(tx.tx)
@@ -83,8 +78,7 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
return tx.store.TeamMembershipService.Tx(tx.tx)
}
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
func (tx *StoreTx) User() dataservices.UserService {

View File

@@ -631,7 +631,6 @@
"LogoURL": "",
"OAuthSettings": {
"AccessTokenURI": "",
"AuthStyle": 0,
"AuthorizationURI": "",
"ClientID": "",
"DefaultTeamID": 0,
@@ -670,7 +669,6 @@
"snapshots": [
{
"Docker": {
"ContainerCount": 0,
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
@@ -678,7 +676,6 @@
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
@@ -941,6 +938,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.22.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

118
api/demo/demo.go Normal file
View File

@@ -0,0 +1,118 @@
package demo
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type EnvironmentDetails struct {
Enabled bool `json:"enabled"`
Users []portainer.UserID `json:"users"`
Environments []portainer.EndpointID `json:"environments"`
}
type Service struct {
details EnvironmentDetails
}
func NewService() *Service {
return &Service{}
}
func (service *Service) Details() EnvironmentDetails {
return service.details
}
func (service *Service) Init(store dataservices.DataStore, cryptoService portainer.CryptoService) error {
log.Info().Msg("starting demo environment")
isClean, err := isCleanStore(store)
if err != nil {
return errors.WithMessage(err, "failed checking if store is clean")
}
if !isClean {
return errors.New(" Demo environment can only be initialized on a clean database")
}
id, err := initDemoUser(store, cryptoService)
if err != nil {
return errors.WithMessage(err, "failed creating demo user")
}
endpointIds, err := initDemoEndpoints(store)
if err != nil {
return errors.WithMessage(err, "failed creating demo endpoint")
}
err = initDemoSettings(store)
if err != nil {
return errors.WithMessage(err, "failed updating demo settings")
}
service.details = EnvironmentDetails{
Enabled: true,
Users: []portainer.UserID{id},
// endpoints 2,3 are created after deployment of portainer
Environments: endpointIds,
}
return nil
}
func isCleanStore(store dataservices.DataStore) (bool, error) {
endpoints, err := store.Endpoint().Endpoints()
if err != nil {
return false, err
}
if len(endpoints) > 0 {
return false, nil
}
users, err := store.User().ReadAll()
if err != nil {
return false, err
}
if len(users) > 0 {
return false, nil
}
return true, nil
}
func (service *Service) IsDemo() bool {
return service.details.Enabled
}
func (service *Service) IsDemoEnvironment(environmentID portainer.EndpointID) bool {
if !service.IsDemo() {
return false
}
for _, demoEndpointID := range service.details.Environments {
if environmentID == demoEndpointID {
return true
}
}
return false
}
func (service *Service) IsDemoUser(userID portainer.UserID) bool {
if !service.IsDemo() {
return false
}
for _, demoUserID := range service.details.Users {
if userID == demoUserID {
return true
}
}
return false
}

88
api/demo/init.go Normal file
View File

@@ -0,0 +1,88 @@
package demo
import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
func initDemoUser(
store dataservices.DataStore,
cryptoService portainer.CryptoService,
) (portainer.UserID, error) {
password, err := cryptoService.Hash("tryportainer")
if err != nil {
return 0, errors.WithMessage(err, "failed creating password hash")
}
admin := &portainer.User{
Username: "admin",
Password: password,
Role: portainer.AdministratorRole,
}
err = store.User().Create(admin)
return admin.ID, errors.WithMessage(err, "failed creating user")
}
func initDemoEndpoints(store dataservices.DataStore) ([]portainer.EndpointID, error) {
localEndpointId, err := initDemoLocalEndpoint(store)
if err != nil {
return nil, err
}
// second and third endpoints are going to be created with docker-compose as a part of the demo environment set up.
// ref: https://github.com/portainer/portainer-demo/blob/master/docker-compose.yml
return []portainer.EndpointID{localEndpointId, localEndpointId + 1, localEndpointId + 2}, nil
}
func initDemoLocalEndpoint(store dataservices.DataStore) (portainer.EndpointID, error) {
id := portainer.EndpointID(store.Endpoint().GetNextIdentifier())
localEndpoint := &portainer.Endpoint{
ID: id,
Name: "local",
URL: "unix:///var/run/docker.sock",
PublicURL: "demo.portainer.io",
Type: portainer.DockerEnvironment,
GroupID: portainer.EndpointGroupID(1),
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: []portainer.TagID{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(),
}
err := store.Endpoint().Create(localEndpoint)
if err != nil {
return id, errors.WithMessage(err, "failed creating local endpoint")
}
err = store.Snapshot().Create(&portainer.Snapshot{EndpointID: id})
if err != nil {
return id, errors.WithMessage(err, "failed creating snapshot")
}
return id, errors.WithMessage(err, "failed creating local endpoint")
}
func initDemoSettings(
store dataservices.DataStore,
) error {
settings, err := store.Settings().Settings()
if err != nil {
return errors.WithMessage(err, "failed fetching settings")
}
settings.EnableTelemetry = false
settings.LogoURL = ""
err = store.Settings().UpdateSettings(settings)
return errors.WithMessage(err, "failed updating settings")
}

View File

@@ -1,23 +1,18 @@
package client
import (
"bytes"
"errors"
"io"
"maps"
"fmt"
"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/image"
"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
@@ -47,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:
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
endpointURL := "http://" + tunnelAddr
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://") {
@@ -92,20 +80,14 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return nil, err
}
opts := []client.Opt{
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
}
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
return client.NewClientWithOpts(opts...)
)
}
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
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
@@ -125,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 {
image.Summary
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)

View File

@@ -5,5 +5,4 @@ const (
SwarmStackNameLabel = "com.docker.stack.namespace"
SwarmServiceIdLabel = "com.docker.swarm.service.id"
SwarmNodeIdLabel = "com.docker.swarm.node.id"
HideStackLabel = "io.portainer.hideStack"
)

View File

@@ -119,7 +119,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
for _, network := range container.NetworkSettings.Networks {
cli.NetworkConnect(ctx, network.NetworkID, containerId, network)
}
cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{})
cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{})
})
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
@@ -135,7 +135,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{})
})
if err != nil {
@@ -164,14 +164,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// 8. start the new container
log.Debug().Str("container_id", newContainerId).Msg("starting the new container")
err = cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{})
err = cli.ContainerStart(ctx, newContainerId, types.ContainerStartOptions{})
if err != nil {
return nil, errors.Wrap(err, "start container error")
}
// 9. delete the old container
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
_ = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{})
c.sr.disable()

View File

@@ -1,37 +0,0 @@
package docker
import "github.com/docker/docker/api/types"
type ContainerStats struct {
Running int `json:"running"`
Stopped int `json:"stopped"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
Total int `json:"total"`
}
func CalculateContainerStats(containers []types.Container) ContainerStats {
var running, stopped, healthy, unhealthy int
for _, container := range containers {
switch container.State {
case "running":
running++
case "healthy":
running++
healthy++
case "unhealthy":
running++
unhealthy++
case "exited", "stopped":
stopped++
}
}
return ContainerStats{
Running: running,
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
}
}

View File

@@ -1,27 +0,0 @@
package docker
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/stretchr/testify/assert"
)
func TestCalculateContainerStats(t *testing.T) {
containers := []types.Container{
{State: "running"},
{State: "running"},
{State: "exited"},
{State: "stopped"},
{State: "healthy"},
{State: "unhealthy"},
}
stats := CalculateContainerStats(containers)
assert.Equal(t, 4, stats.Running)
assert.Equal(t, 2, stats.Stopped)
assert.Equal(t, 1, stats.Healthy)
assert.Equal(t, 1, stats.Unhealthy)
assert.Equal(t, 6, stats.Total)
}

View File

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

View File

@@ -6,7 +6,6 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
_container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
@@ -148,16 +147,24 @@ func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Clien
}
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
return err
}
runningContainers := 0
stoppedContainers := 0
healthyContainers := 0
unhealthyContainers := 0
stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{})
gpuUseAll := false
for _, container := range containers {
if container.State == "running" {
if container.State == "exited" || container.State == "stopped" {
stoppedContainers++
} else if container.State == "running" {
runningContainers++
// snapshot GPUs
response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil {
@@ -194,6 +201,12 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
}
}
if strings.Contains(container.Status, "(healthy)") {
healthyContainers++
} else if strings.Contains(container.Status, "(unhealthy)") {
unhealthyContainers++
}
for k, v := range container.Labels {
if k == consts.ComposeStackNameLabel {
stacks[v] = struct{}{}
@@ -209,13 +222,10 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList
stats := CalculateContainerStats(containers)
snapshot.ContainerCount = stats.Total
snapshot.RunningContainerCount = stats.Running
snapshot.StoppedContainerCount = stats.Stopped
snapshot.HealthyContainerCount = stats.Healthy
snapshot.UnhealthyContainerCount = stats.Unhealthy
snapshot.RunningContainerCount = runningContainers
snapshot.StoppedContainerCount = stoppedContainers
snapshot.HealthyContainerCount = healthyContainers
snapshot.UnhealthyContainerCount = unhealthyContainers
snapshot.StackCount += len(stacks)
for _, container := range containers {
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})

View File

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

View File

@@ -76,9 +76,15 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
defer proxy.Close()
}
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
}
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{
WorkingDir: "",
Host: url,
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
})
return errors.Wrap(err, "failed to remove a stack")
@@ -142,46 +148,28 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
}
defer envfile.Close()
// Copy from default .env file
defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if err = copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
return "", err
}
copyDefaultEnvFile(stack, envfile)
// Copy from stack env vars
if err = copyConfigEnvVars(envfile, stack.Env); err != nil {
return "", err
for _, v := range stack.Env {
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
}
return "stack.env", nil
}
// copyDefaultEnvFile copies the default .env file if it exists to the provided writer
func copyDefaultEnvFile(w io.Writer, defaultEnvFilePath string) error {
defaultEnvFile, err := os.Open(defaultEnvFilePath)
func copyDefaultEnvFile(stack *portainer.Stack, w io.Writer) {
defaultEnvFile, err := os.Open(path.Join(path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint)), ".env"))
if err != nil {
// If cannot open a default file, then don't need to copy it.
// We could as well stat it and check if it exists, but this is more efficient.
return nil
return
}
defer defaultEnvFile.Close()
if _, err = io.Copy(w, defaultEnvFile); err == nil {
if _, err = fmt.Fprintf(w, "\n"); err != nil {
return fmt.Errorf("failed to copy default env file: %w", err)
}
io.WriteString(w, "\n")
}
return nil
// If couldn't copy the .env file, then ignore the error and try to continue
}
// copyConfigEnvVars write the environment variables from stack configuration to the writer
func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
for _, v := range envs {
if _, err := fmt.Fprintf(w, "%s=%s\n", v.Name, v.Value); err != nil {
return fmt.Errorf("failed to copy config env vars: %w", err)
}
}
return nil
}

View File

@@ -23,7 +23,6 @@ func Test_createEnvFile(t *testing.T) {
name: "should not add env file option if stack doesn't have env variables",
stack: &portainer.Stack{
ProjectPath: dir,
Env: nil,
},
expected: "",
},

View File

@@ -3,6 +3,7 @@ package exec
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path"
@@ -157,10 +158,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
if workingDir != "" {
cmd.Dir = workingDir
}
cmd.Dir = workingDir
if env != nil {
cmd.Env = os.Environ()
@@ -188,11 +186,11 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
tunnel, err := manager.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
}
args = append(args, "-H", endpointURL)

View File

@@ -934,7 +934,7 @@ func FileExists(filePath string) (bool, error) {
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
// 1. Backup the source directory to a different folder
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
err := MoveDirectory(originalPath, backupDir, false)
err := MoveDirectory(originalPath, backupDir)
if err != nil {
return fmt.Errorf("failed to backup source directory: %w", err)
}
@@ -973,14 +973,14 @@ func restoreBackup(src, backupDir string) error {
return fmt.Errorf("failed to delete destination directory: %w", err)
}
err = MoveDirectory(backupDir, src, false)
err = MoveDirectory(backupDir, src)
if err != nil {
return fmt.Errorf("failed to restore backup directory: %w", err)
}
return nil
}
func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error {
func MoveDirectory(originalPath, newPath string) error {
if _, err := os.Stat(originalPath); err != nil {
return err
}
@@ -991,13 +991,7 @@ func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error
}
if alreadyExists {
if !overwriteTargetPath {
return fmt.Errorf("Target path already exists")
}
if err = os.RemoveAll(newPath); err != nil {
return fmt.Errorf("failed to overwrite path %s: %s", newPath, err.Error())
}
return errors.New("Target path already exists")
}
return os.Rename(originalPath, newPath)

View File

@@ -16,7 +16,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
file1 := addFile(destinationDir, "dir", "file")
file2 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, false)
err := MoveDirectory(sourceDir, destinationDir)
assert.Error(t, err, "move directory should fail when source path is missing")
assert.FileExists(t, file1, "destination dir contents should remain")
assert.FileExists(t, file2, "destination dir contents should remain")
@@ -30,7 +30,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, false)
err := MoveDirectory(sourceDir, destinationDir)
assert.Error(t, err, "move directory should fail when destination directory already exists")
assert.FileExists(t, file1, "source dir contents should remain")
assert.FileExists(t, file2, "source dir contents should remain")
@@ -38,22 +38,6 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
sourceDir := t.TempDir()
file1 := addFile(sourceDir, "dir", "file")
file2 := addFile(sourceDir, "file")
destinationDir := t.TempDir()
file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, true)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assert.FileExists(t, file3, "destination dir contents should remain")
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
tmp := t.TempDir()
sourceDir := path.Join(tmp, "source")
@@ -62,7 +46,7 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
file2 := addFile(sourceDir, "file")
destinationDir := path.Join(tmp, "destination")
err := MoveDirectory(sourceDir, destinationDir, false)
err := MoveDirectory(sourceDir, destinationDir)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")

View File

@@ -38,7 +38,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
}
}
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath, true)
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath)
if err != nil {
return cleanFn, errors.WithMessage(err, "Unable to move git repository directory")
}
@@ -48,7 +48,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify)
if err != nil {
cleanUp = false
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false)
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath)
if restoreError != nil {
log.Warn().Err(restoreError).Msg("failed restoring backup folder")
}

View File

@@ -21,11 +21,7 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
handler = gorillacsrf.Protect(
token,
gorillacsrf.Path("/"),
gorillacsrf.Secure(false),
)(handler)
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
return withSkipCSRF(handler), nil
}

View File

@@ -9,4 +9,6 @@ var (
ErrUnauthorized = errors.New("Unauthorized")
// ErrResourceAccessDenied Access denied to resource error
ErrResourceAccessDenied = errors.New("Access denied to resource")
// ErrNotAvailableInDemo feature is not allowed in demo
ErrNotAvailableInDemo = errors.New("This feature is not available in the demo version of Portainer")
)

View File

@@ -75,12 +75,7 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
if settings.AuthenticationMethod == portainer.AuthenticationInternal ||
settings.AuthenticationMethod == portainer.AuthenticationOAuth ||
(settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) {
// avoid username enumeration timing attack by creating a fake user
// https://en.wikipedia.org/wiki/Timing_attack
user = &portainer.User{
Username: "portainer-fake-username",
Password: "$2a$10$abcdefghijklmnopqrstuvwx..ABCDEFGHIJKLMNOPQRSTUVWXYZ12", // fake but valid format bcrypt hash
}
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
}
}
@@ -117,11 +112,7 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
if err != nil {
if errors.Is(err, httperrors.ErrUnauthorized) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
}
return httperror.InternalServerError("Unable to authenticate user against LDAP", err)
return httperror.Forbidden("Only initial admin is allowed to login without oauth", err)
}
if user == nil {

View File

@@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -54,7 +55,8 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T)
gate,
"./test_assets/handler_test",
func() {},
adminMonitor).backup(w, r)
adminMonitor,
&demo.Service{}).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()
@@ -97,7 +99,8 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
gate,
"./test_assets/handler_test",
func() {},
adminMonitor).backup(w, r)
adminMonitor,
&demo.Service{}).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()

View File

@@ -6,6 +6,8 @@ import (
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -32,7 +34,10 @@ func NewHandler(
filestorePath string,
shutdownTrigger context.CancelFunc,
adminMonitor *adminmonitor.Monitor,
demoService *demo.Service,
) *Handler {
h := &Handler{
Router: mux.NewRouter(),
bouncer: bouncer,
@@ -43,8 +48,11 @@ func NewHandler(
adminMonitor: adminMonitor,
}
h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
demoRestrictedRouter := h.NewRoute().Subrouter()
demoRestrictedRouter.Use(middlewares.RestrictDemoEnv(demoService.IsDemo))
demoRestrictedRouter.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
demoRestrictedRouter.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
return h
}

View File

@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -62,6 +63,7 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
"./test_assets/handler_test",
func() {},
adminMonitor,
&demo.Service{},
)
//backup
@@ -94,6 +96,7 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
"./test_assets/handler_test",
func() {},
adminMonitor,
&demo.Service{},
)
//backup

View File

@@ -482,7 +482,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
}
templateFolder := strconv.Itoa(customTemplateID)
projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, payload.FileContent)
projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
if err != nil {
return nil, err
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,164 +0,0 @@
package docker
import (
"net/http"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/handler/docker/utils"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type imagesCounters struct {
Total int `json:"total"`
Size int64 `json:"size"`
}
type dashboardResponse struct {
Containers docker.ContainerStats `json:"containers"`
Services int `json:"services"`
Images imagesCounters `json:"images"`
Volumes int `json:"volumes"`
Networks int `json:"networks"`
Stacks int `json:"stacks"`
}
// @id dockerDashboard
// @summary Get counters for the dashboard
// @description **Access policy**: restricted
// @tags docker
// @security jwt
// @param environmentId path int true "Environment identifier"
// @accept json
// @produce json
// @success 200 {object} dashboardResponse "Success"
// @failure 400 "Bad request"
// @failure 500 "Internal server error"
// @router /docker/{environmentId}/dashboard [post]
func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var resp dashboardResponse
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
cli, httpErr := utils.GetClient(r, h.dockerClientFactory)
if httpErr != nil {
return httpErr
}
context, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from request context", err)
}
containers, err := cli.ContainerList(r.Context(), container.ListOptions{All: true})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
}
containers, err = utils.FilterByResourceControl(tx, containers, portainer.ContainerResourceControl, context, func(c types.Container) string {
return c.ID
})
if err != nil {
return err
}
images, err := cli.ImageList(r.Context(), image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
var totalSize int64
for _, image := range images {
totalSize += image.Size
}
info, err := cli.Info(r.Context())
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker info", err)
}
isSwarmManager := info.Swarm.ControlAvailable && info.Swarm.NodeID != ""
var services []swarm.Service
if isSwarmManager {
servicesRes, err := cli.ServiceList(r.Context(), types.ServiceListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker services", err)
}
filteredServices, err := utils.FilterByResourceControl(tx, servicesRes, portainer.ServiceResourceControl, context, func(c swarm.Service) string {
return c.ID
})
if err != nil {
return err
}
services = filteredServices
}
volumesRes, err := cli.VolumeList(r.Context(), volume.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker volumes", err)
}
volumes, err := utils.FilterByResourceControl(tx, volumesRes.Volumes, portainer.NetworkResourceControl, context, func(c *volume.Volume) string {
return c.Name
})
if err != nil {
return err
}
networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
}
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
return c.Name
})
if err != nil {
return err
}
environment, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment", err)
}
stackCount := 0
if environment.SecuritySettings.AllowStackManagementForRegularUsers || context.IsAdmin {
stacks, err := utils.GetDockerStacks(tx, context, environment.ID, containers, services)
if err != nil {
return httperror.InternalServerError("Unable to retrieve stacks", err)
}
stackCount = len(stacks)
}
resp = dashboardResponse{
Images: imagesCounters{
Total: len(images),
Size: totalSize,
},
Services: len(services),
Containers: docker.CalculateContainerStats(containers),
Networks: len(networks),
Volumes: len(volumes),
Stacks: stackCount,
}
return nil
})
return errors.TxResponse(err, func() *httperror.HandlerError {
return response.JSON(w, resp)
})
}

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
package utils
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/slices"
)
// filterByResourceControl filters a list of items based on the user's role and the resource control associated to the item.
func FilterByResourceControl[T any](tx dataservices.DataStoreTx, items []T, rcType portainer.ResourceControlType, securityContext *security.RestrictedRequestContext, idGetter func(T) string) ([]T, error) {
if securityContext.IsAdmin {
return items, nil
}
userTeamIDs := slices.Map(securityContext.UserMemberships, func(membership portainer.TeamMembership) portainer.TeamID {
return membership.TeamID
})
filteredItems := make([]T, 0)
for _, item := range items {
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(idGetter(item), portainer.ContainerResourceControl)
if err != nil {
return nil, fmt.Errorf("Unable to retrieve resource control: %w", err)
}
if resourceControl == nil || authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) {
filteredItems = append(filteredItems, item)
}
}
return filteredItems, nil
}

View File

@@ -1,83 +0,0 @@
package utils
import (
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
portainer "github.com/portainer/portainer/api"
portaineree "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerconsts "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/security"
)
type StackViewModel struct {
InternalStack *portaineree.Stack
ID portainer.StackID
Name string
IsExternal bool
Type portainer.StackType
}
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, environmentID portainer.EndpointID, containers []types.Container, services []swarm.Service) ([]StackViewModel, error) {
stacks, err := tx.Stack().ReadAll()
if err != nil {
return nil, fmt.Errorf("Unable to retrieve stacks: %w", err)
}
stacksNameSet := map[string]*StackViewModel{}
for i := range stacks {
stack := stacks[i]
if stack.EndpointID == environmentID {
stacksNameSet[stack.Name] = &StackViewModel{
InternalStack: &stack,
ID: stack.ID,
Name: stack.Name,
IsExternal: false,
Type: stack.Type,
}
}
}
for _, container := range containers {
name := container.Labels[dockerconsts.ComposeStackNameLabel]
if name != "" && stacksNameSet[name] == nil && !isHiddenStack(container.Labels) {
stacksNameSet[name] = &StackViewModel{
Name: name,
IsExternal: true,
Type: portainer.DockerComposeStack,
}
}
}
for _, service := range services {
name := service.Spec.Labels[dockerconsts.SwarmStackNameLabel]
if name != "" && stacksNameSet[name] == nil && !isHiddenStack(service.Spec.Labels) {
stacksNameSet[name] = &StackViewModel{
Name: name,
IsExternal: true,
Type: portainer.DockerSwarmStack,
}
}
}
stacksList := make([]StackViewModel, 0)
for _, stack := range stacksNameSet {
stacksList = append(stacksList, *stack)
}
return FilterByResourceControl(tx, stacksList, portainer.StackResourceControl, securityContext, func(c StackViewModel) string {
return c.Name
})
}
func isHiddenStack(labels map[string]string) bool {
return labels[dockerconsts.HideStackLabel] != ""
}

View File

@@ -1,96 +0,0 @@
package utils
import (
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
portainer "github.com/portainer/portainer/api"
portaineree "github.com/portainer/portainer/api"
dockerconsts "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func TestHandler_getDockerStacks(t *testing.T) {
environment := &portaineree.Endpoint{
ID: 1,
SecuritySettings: portainer.EndpointSecuritySettings{
AllowStackManagementForRegularUsers: true,
},
}
containers := []types.Container{
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack1",
},
},
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack2",
},
},
}
services := []swarm.Service{
{
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{
Labels: map[string]string{
dockerconsts.SwarmStackNameLabel: "stack3",
},
},
},
},
}
stack1 := portaineree.Stack{
ID: 1,
Name: "stack1",
EndpointID: 1,
Type: portainer.DockerComposeStack,
}
datastore := testhelpers.NewDatastore(
testhelpers.WithEndpoints([]portaineree.Endpoint{*environment}),
testhelpers.WithStacks([]portaineree.Stack{
stack1,
{
ID: 2,
Name: "stack2",
EndpointID: 2,
Type: portainer.DockerSwarmStack,
},
}),
)
stacksList, err := GetDockerStacks(datastore, &security.RestrictedRequestContext{
IsAdmin: true,
}, environment.ID, containers, services)
assert.NoError(t, err)
assert.Len(t, stacksList, 3)
expectedStacks := []StackViewModel{
{
InternalStack: &stack1,
ID: 1,
Name: "stack1",
IsExternal: false,
Type: portainer.DockerComposeStack,
},
{
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
},
{
Name: "stack3",
IsExternal: true,
Type: portainer.DockerSwarmStack,
},
}
assert.ElementsMatch(t, expectedStacks, stacksList)
}

View File

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

View File

@@ -69,7 +69,7 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error)
for _, orgEdgeGroup := range edgeGroups {
usedByEdgeJob := false
for _, edgeJob := range edgeJobs {
if slices.Contains(edgeJob.EdgeGroups, orgEdgeGroup.ID) {
if slices.Contains(edgeJob.EdgeGroups, portainer.EdgeGroupID(orgEdgeGroup.ID)) {
usedByEdgeJob = true
break
}

View File

@@ -49,7 +49,7 @@ func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *h
}
func (handler *Handler) deleteEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) error {
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
} else if err != nil {

View File

@@ -75,7 +75,7 @@ func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request
}
func (handler *Handler) clearEdgeJobTaskLogs(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, endpointID portainer.EndpointID, updateEdgeJob func(*portainer.EdgeJob, portainer.EndpointID, []portainer.EndpointID) error) error {
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
} else if err != nil {

View File

@@ -43,5 +43,5 @@ func (handler *Handler) edgeJobTaskLogsInspect(w http.ResponseWriter, r *http.Re
return httperror.InternalServerError("Unable to retrieve log file from disk", err)
}
return response.JSON(w, &fileResponse{FileContent: logFileContent})
return response.JSON(w, &fileResponse{FileContent: string(logFileContent)})
}

View File

@@ -47,7 +47,7 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request)
}
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]taskContainer, error) {
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID))
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
} else if err != nil {

View File

@@ -48,7 +48,7 @@ func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error {
// @failure 500
// @failure 400
// @failure 503 "Edge compute features are disabled"
// @router /edge_jobs/{id} [put]
// @router /edge_jobs/{id} [post]
func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
@@ -71,7 +71,7 @@ func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *h
}
func (handler *Handler) updateEdgeJob(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID, payload edgeJobUpdatePayload) (*portainer.EdgeJob, error) {
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
edgeJob, err := tx.EdgeJob().Read(portainer.EdgeJobID(edgeJobID))
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
} else if err != nil {
@@ -137,7 +137,7 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
}
for _, endpointID := range endpoints {
endpointsToRemove[endpointID] = true
endpointsToRemove[portainer.EndpointID(endpointID)] = true
}
edgeJob.EdgeGroups = nil

View File

@@ -45,7 +45,7 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
}
func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID) error {
edgeStack, err := tx.EdgeStack().EdgeStack(edgeStackID)
edgeStack, err := tx.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", err)
} else if err != nil {

View File

@@ -61,7 +61,7 @@ func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Req
}
func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
stack, err := tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}

View File

@@ -135,11 +135,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
}
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
delete(stack.Status, environmentId)
return
}
environmentStatus, ok := stack.Status[environmentId]
if !ok {
environmentStatus = portainer.EdgeStackStatus{

View File

@@ -81,7 +81,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type)
}
if endpointStatus.EndpointID != payload.EndpointID {
if endpointStatus.EndpointID != portainer.EndpointID(payload.EndpointID) {
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
}

View File

@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
stack, err := tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}

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