Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5d5cab68 | ||
|
|
51ffea9c93 | ||
|
|
eb0ee117a5 | ||
|
|
c52767fb04 | ||
|
|
8e39a16172 | ||
|
|
e964be75db | ||
|
|
6776b01ac8 | ||
|
|
b96031965a | ||
|
|
b2a2e5c222 | ||
|
|
27285a94ac | ||
|
|
b3f01973ec | ||
|
|
17ffd62480 | ||
|
|
86f6aba362 | ||
|
|
718e11ccd0 | ||
|
|
e68b0e80f1 | ||
|
|
9a14f2acb7 | ||
|
|
01ff1486e0 | ||
|
|
b91f77a554 | ||
|
|
b9713f7e9e | ||
|
|
9c0a13a828 | ||
|
|
dc56aae7b8 | ||
|
|
ba11fe920b | ||
|
|
7f2da7811c | ||
|
|
62cf2e42d5 | ||
|
|
64745e70d0 | ||
|
|
f49cd6e932 | ||
|
|
ac1e333dde | ||
|
|
b5bc5f65ad | ||
|
|
463d539194 | ||
|
|
7e544ee449 | ||
|
|
1f320c976f | ||
|
|
825a7669a6 | ||
|
|
f6a72b089c | ||
|
|
73ea33f36c | ||
|
|
744a31a354 | ||
|
|
42c7f10e79 | ||
|
|
3e57bc5aa0 | ||
|
|
4880e61e0f | ||
|
|
79a93cfd01 | ||
|
|
0af7bc2004 | ||
|
|
ada103e910 | ||
|
|
a0e964c27d | ||
|
|
a2624b7467 | ||
|
|
9abd7eaeea | ||
|
|
3502ed0293 | ||
|
|
3101738adc | ||
|
|
0b390dd274 | ||
|
|
9d3f7b710d | ||
|
|
3a8ed40943 | ||
|
|
aef1d982c2 | ||
|
|
b287961758 | ||
|
|
8d5675a7d7 | ||
|
|
544e302fe1 | ||
|
|
b417b04a69 | ||
|
|
6ecb99898d | ||
|
|
236c5e2415 | ||
|
|
2d2b68e867 | ||
|
|
f841ea527a | ||
|
|
169548cc4c | ||
|
|
8f93a1a8cf | ||
|
|
8e85fa9f83 | ||
|
|
181a83a889 | ||
|
|
b78504aa04 | ||
|
|
a21ec9299b | ||
|
|
7708ace1d8 | ||
|
|
218b5d5900 | ||
|
|
2983b94cf7 | ||
|
|
25e082ea63 | ||
|
|
3313376fac | ||
|
|
a96c6efcbd | ||
|
|
4dd6b88cdf | ||
|
|
0d836f1e30 | ||
|
|
ab3e0956a4 | ||
|
|
615fceb4a5 | ||
|
|
68453ebcb8 | ||
|
|
635c49d04d | ||
|
|
886af7d55a | ||
|
|
8f563220df | ||
|
|
def415b6f3 | ||
|
|
c21d043183 | ||
|
|
769ea73cec | ||
|
|
d140726c46 | ||
|
|
1f42559279 | ||
|
|
b6d6c7fd2a | ||
|
|
1298fc629e | ||
|
|
30ca5e298c | ||
|
|
2240d0516c | ||
|
|
b87095dc7a | ||
|
|
d30503a40c | ||
|
|
7fbda4fe54 | ||
|
|
24a2b29f70 | ||
|
|
ca9e197d12 | ||
|
|
51f86eb4c6 | ||
|
|
5aba61cc49 | ||
|
|
fcf9888677 | ||
|
|
9c9caeb57a | ||
|
|
a58ad25533 | ||
|
|
11f5150190 | ||
|
|
1c72dfe5ad | ||
|
|
b49830db8f | ||
|
|
e035c490dc | ||
|
|
0d8544b3ee | ||
|
|
50056bef70 | ||
|
|
e68e14787b | ||
|
|
0ab2c5cf98 | ||
|
|
1ca56fd027 | ||
|
|
c4cc9cf1c7 | ||
|
|
b53684a89e | ||
|
|
d93508a272 | ||
|
|
ad9b9cf5b1 | ||
|
|
ac5fb731bc | ||
|
|
d36799020b | ||
|
|
7aa08053e0 | ||
|
|
61b9bc248f | ||
|
|
e33f9573e8 | ||
|
|
186624d267 | ||
|
|
7c9d4cd7d8 | ||
|
|
541b8df735 | ||
|
|
2900bfa1d6 | ||
|
|
5ea0f682a6 | ||
|
|
019cbfd972 | ||
|
|
792c95b8bb |
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test/
|
||||
162
.eslintrc.yml
162
.eslintrc.yml
@@ -1,162 +0,0 @@
|
||||
env:
|
||||
browser: true
|
||||
jquery: true
|
||||
node: true
|
||||
es6: true
|
||||
|
||||
globals:
|
||||
angular: true
|
||||
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- 'plugin:import/typescript'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: latest
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
modules: true
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
no-alert: error
|
||||
no-control-regex: 'off'
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/named: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups:
|
||||
[
|
||||
{ pattern: '@@/**', group: 'internal', position: 'after' },
|
||||
{ pattern: '@/**', group: 'internal' },
|
||||
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
|
||||
],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
no-restricted-imports:
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
alias:
|
||||
map:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
typescript: true
|
||||
node: true
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
- app/**/*.ts{,x}
|
||||
parserOptions:
|
||||
project: './tsconfig.json'
|
||||
parser: '@typescript-eslint/parser'
|
||||
plugins:
|
||||
- '@typescript-eslint'
|
||||
- 'regex'
|
||||
extends:
|
||||
- airbnb
|
||||
- airbnb-typescript
|
||||
- 'plugin:eslint-comments/recommended'
|
||||
- 'plugin:react-hooks/recommended'
|
||||
- 'plugin:react/jsx-runtime'
|
||||
- 'plugin:@typescript-eslint/recommended'
|
||||
- 'plugin:@typescript-eslint/eslint-recommended'
|
||||
- 'plugin:promise/recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- prettier # should be last
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
]
|
||||
no-plusplus: off
|
||||
func-style: [error, 'declaration']
|
||||
import/prefer-default-export: off
|
||||
no-use-before-define: 'off'
|
||||
'@typescript-eslint/no-use-before-define': ['error', { functions: false, 'allowNamedExports': true }]
|
||||
no-shadow: 'off'
|
||||
'@typescript-eslint/no-shadow': off
|
||||
jsx-a11y/no-autofocus: warn
|
||||
react/forbid-prop-types: off
|
||||
react/require-default-props: off
|
||||
react/no-array-index-key: off
|
||||
no-underscore-dangle: off
|
||||
react/jsx-filename-extension: [0]
|
||||
import/no-extraneous-dependencies: ['error', { devDependencies: true }]
|
||||
'@typescript-eslint/explicit-module-boundary-types': off
|
||||
'@typescript-eslint/no-unused-vars': 'error'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'jsx-a11y/label-has-associated-control':
|
||||
- error
|
||||
- assert: either
|
||||
controlComponents:
|
||||
- Input
|
||||
- Checkbox
|
||||
'jsx-a11y/control-has-associated-label': off
|
||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||
'react/jsx-no-bind': off
|
||||
'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}
|
||||
rules:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
plugins:
|
||||
- '@vitest'
|
||||
extends:
|
||||
- 'plugin:@vitest/legacy-recommended'
|
||||
env:
|
||||
'@vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'@vitest/no-conditional-expect': warn
|
||||
'max-classes-per-file': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
'no-alert': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'storybook/no-renderer-packages': off
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,6 +94,7 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.40.0'
|
||||
- '2.39.1'
|
||||
- '2.39.0'
|
||||
- '2.38.1'
|
||||
@@ -142,7 +143,6 @@ body:
|
||||
- '2.22.0'
|
||||
- '2.21.5'
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ linters:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
- pattern: ^(filepath|path)\.Join$
|
||||
msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
15
.prettierrc
15
.prettierrc
@@ -5,21 +5,18 @@
|
||||
"trailingComma": "es5",
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.{j,t}sx",
|
||||
"*.ts"
|
||||
],
|
||||
"files": ["*.{j,t}sx", "*.ts"],
|
||||
"options": {
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["clsx"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'path';
|
||||
|
||||
import { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
||||
import { Configuration } from 'webpack';
|
||||
import postcss from 'postcss';
|
||||
|
||||
@@ -85,12 +86,7 @@ const config: StorybookConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
plugins: [
|
||||
...(config.resolve?.plugins || []),
|
||||
new TsconfigPathsPlugin({
|
||||
extensions: config.resolve?.extensions,
|
||||
}),
|
||||
],
|
||||
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
|
||||
},
|
||||
module: {
|
||||
...config.module,
|
||||
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
|
||||
staticDirs: ['./public'],
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgenTypescriptOptions: {
|
||||
compilerOptions: {
|
||||
outDir: path.resolve(__dirname, '..', 'dist/public'),
|
||||
},
|
||||
},
|
||||
},
|
||||
framework: {
|
||||
name: '@storybook/react-webpack5',
|
||||
options: {},
|
||||
},
|
||||
docs: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -35,6 +35,11 @@ const preview: Preview = {
|
||||
),
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
order: ['Design System', 'Components', '*'],
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Mock Service Worker (2.0.11).
|
||||
* Mock Service Worker.
|
||||
* @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 PACKAGE_VERSION = '2.12.10';
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
|
||||
const activeClientIds = new Set();
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id;
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id');
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return;
|
||||
@@ -48,7 +50,10 @@ self.addEventListener('message', async function (event) {
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -58,16 +63,16 @@ self.addEventListener('message', async function (event) {
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId);
|
||||
|
||||
@@ -85,72 +90,91 @@ self.addEventListener('message', async function (event) {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event;
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now();
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
if (event.request.mode === 'navigate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID();
|
||||
event.respondWith(handleRequest(event, requestId));
|
||||
const requestId = uuidv4();
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
|
||||
});
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
const requestCloneForEvents = event.request.clone();
|
||||
const response = await getResponse(event, client, requestId, requestInterceptedAt);
|
||||
|
||||
// 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();
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
[responseClone.body]
|
||||
);
|
||||
})();
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, 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.
|
||||
/**
|
||||
* 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.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
@@ -171,20 +195,37 @@ async function resolveMainClient(event) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// 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();
|
||||
const requestClone = event.request.clone();
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries());
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers);
|
||||
|
||||
// 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'];
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept');
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim());
|
||||
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '));
|
||||
} else {
|
||||
headers.delete('accept');
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers });
|
||||
}
|
||||
@@ -202,37 +243,19 @@ async function getResponse(event, client, requestId) {
|
||||
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 serializedRequest = await serializeRequest(event.request);
|
||||
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,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[requestBuffer]
|
||||
[serializedRequest.body]
|
||||
);
|
||||
|
||||
switch (clientMessage.type) {
|
||||
@@ -240,7 +263,7 @@ async function getResponse(event, client, requestId) {
|
||||
return respondWithMock(clientMessage.data);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough();
|
||||
}
|
||||
}
|
||||
@@ -248,6 +271,12 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
@@ -260,11 +289,15 @@ function sendToClient(client, message, transferrables = []) {
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
|
||||
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
|
||||
});
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
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
|
||||
@@ -282,3 +315,24 @@ async function respondWithMock(response) {
|
||||
|
||||
return mockedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
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: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
};
|
||||
}
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -2,11 +2,22 @@
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
see also:
|
||||
## Project Structure
|
||||
|
||||
- docs/guidelines/server-architecture.md
|
||||
- docs/guidelines/go-conventions.md
|
||||
- docs/guidelines/typescript-conventions.md
|
||||
For a detailed breakdown of frontend and backend directory layout, feature locations, and common development tasks, see [docs/guidelines/project-structure.md](../../docs/guidelines/project-structure.md).
|
||||
|
||||
## Frontend Guidelines
|
||||
|
||||
- [docs/guidelines/frontend-conventions.md](../../docs/guidelines/frontend-conventions.md) — component structure, React Query patterns, shared components, forms, theming
|
||||
- [docs/guidelines/typescript-conventions.md](../../docs/guidelines/typescript-conventions.md) — types, anti-patterns, union types, named constants
|
||||
- [docs/guidelines/frontend-unit-testing.md](../../docs/guidelines/frontend-unit-testing.md) — Vitest, React Testing Library
|
||||
|
||||
## Backend Guidelines
|
||||
|
||||
- [docs/guidelines/go-conventions.md](../../docs/guidelines/go-conventions.md) — error handling, naming, testing, code style
|
||||
- [docs/guidelines/server-architecture.md](../../docs/guidelines/server-architecture.md) — Clean Architecture layers, transactions, CE/EE sharing patterns
|
||||
- [docs/guidelines/logging.md](../../docs/guidelines/logging.md) — zerolog usage, log levels, message style
|
||||
- [docs/guidelines/backend-code-reusability.md](../../docs/guidelines/backend-code-reusability.md) — how CE and EE share backend code
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -27,9 +38,13 @@ make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
pnpm run dev # Webpack dev server
|
||||
pnpm run build # Build frontend with webpack
|
||||
pnpm run test # Run frontend tests
|
||||
# Frontend
|
||||
pnpm dev # Webpack dev server
|
||||
pnpm build # Build frontend with webpack
|
||||
pnpm typecheck # Run typecheck for frontend (with tsc)
|
||||
pnpm lint # lint frontend (with eslint)
|
||||
pnpm test # test frontend (with vitest)
|
||||
pnpm format # format frontend (with prettier)
|
||||
|
||||
# Testing
|
||||
make test # All tests (backend + frontend)
|
||||
|
||||
14
Makefile
14
Makefile
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||
TAG=local
|
||||
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@latest
|
||||
GOTESTSUM_VERSION?=v1.13.0
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
|
||||
|
||||
# Don't change anything below this line unless you know what you're doing
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
|
||||
test-client: ## Run client tests
|
||||
pnpm run test $(ARGS) --coverage
|
||||
|
||||
TEST_PACKAGES?=./...
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
|
||||
|
||||
##@ Dev
|
||||
.PHONY: dev dev-client dev-server
|
||||
@@ -112,6 +115,13 @@ docs-validate: docs-build ## Validate docs
|
||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
pnpm swagger-cli validate dist/docs/openapi.yaml
|
||||
|
||||
.PHONY: docs-serve
|
||||
docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
|
||||
docker run -p 8080:8080 \
|
||||
-e SWAGGER_JSON=/foo/swagger.yaml \
|
||||
-v $(PWD)/dist/docs:/foo \
|
||||
swaggerapi/swagger-ui
|
||||
|
||||
##@ Helpers
|
||||
.PHONY: help
|
||||
help: ## Display this help
|
||||
|
||||
@@ -19,24 +19,22 @@ const RedirectReasonAdminInitTimeout string = "AdminInitTimeout"
|
||||
type Monitor struct {
|
||||
timeout time.Duration
|
||||
datastore dataservices.DataStore
|
||||
shutdownCtx context.Context
|
||||
cancellationFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
adminInitDisabled bool
|
||||
}
|
||||
|
||||
// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized.
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore, shutdownCtx context.Context) *Monitor {
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore) *Monitor {
|
||||
return &Monitor{
|
||||
timeout: timeout,
|
||||
datastore: datastore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
adminInitDisabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Starts starts the monitor. Active monitor could be stopped or shuttted down by cancelling the shutdown context.
|
||||
func (m *Monitor) Start() {
|
||||
// Start starts the monitor. The monitor will stop when ctx is cancelled, or when Stop is called.
|
||||
func (m *Monitor) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -44,7 +42,7 @@ func (m *Monitor) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(context.Background())
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(ctx)
|
||||
m.cancellationFunc = cancellationFunc
|
||||
|
||||
go func() {
|
||||
@@ -69,8 +67,6 @@ func (m *Monitor) Start() {
|
||||
}
|
||||
case <-cancellationCtx.Done():
|
||||
log.Debug().Msg("canceling initialization monitor")
|
||||
case <-m.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down initialization monitor")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package adminmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -11,21 +11,28 @@ import (
|
||||
)
|
||||
|
||||
func Test_stopWithoutStarting(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_stopCouldBeCalledMultipleTimes(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
t.Parallel()
|
||||
synctest.Test(t, test_startOrStopCouldBeCalledMultipleTimesConcurrently)
|
||||
}
|
||||
|
||||
go monitor.Start()
|
||||
monitor.Start()
|
||||
func test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil)
|
||||
|
||||
go monitor.Start(t.Context())
|
||||
monitor.Start(t.Context())
|
||||
|
||||
go monitor.Stop()
|
||||
monitor.Stop()
|
||||
@@ -34,8 +41,9 @@ func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_canStopStartedMonitor(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
monitor.Start()
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Start(t.Context())
|
||||
assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor")
|
||||
|
||||
monitor.Stop()
|
||||
@@ -43,11 +51,12 @@ func Test_canStopStartedMonitor(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
timeout := 10 * time.Millisecond
|
||||
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}))
|
||||
monitor := New(timeout, datastore, context.Background())
|
||||
monitor.Start()
|
||||
monitor := New(timeout, datastore)
|
||||
monitor.Start(t.Context())
|
||||
|
||||
<-time.After(20 * timeout)
|
||||
assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_generateRandomKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -71,7 +71,7 @@ func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||
for _, k := range c.cache.Keys() {
|
||||
user, _, _ := c.Get(k.(string))
|
||||
if c.userCmpFn(user, userId) {
|
||||
present = c.cache.Remove(k)
|
||||
present = c.cache.Remove(k) || present
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_apiKeyCacheGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -43,6 +44,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -68,6 +70,7 @@ func Test_apiKeyCacheSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
@@ -87,6 +90,7 @@ func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
@@ -148,6 +152,7 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
)
|
||||
|
||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil))
|
||||
}
|
||||
|
||||
func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -75,6 +77,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -94,6 +97,7 @@ func Test_GetAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -114,6 +118,7 @@ func Test_GetAPIKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -149,6 +154,7 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_UpdateAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -197,6 +203,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -237,6 +244,7 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -34,24 +33,25 @@ func listFiles(dir string) []string {
|
||||
}
|
||||
|
||||
func Test_shouldCreateArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
||||
@@ -61,7 +61,7 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, err := os.ReadFile(fullpath)
|
||||
require.NoError(t, err)
|
||||
@@ -74,24 +74,25 @@ func Test_shouldCreateArchive(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_shouldCreateArchive2(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
r, _ := os.Open(gzPath)
|
||||
@@ -101,7 +102,7 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
assert.Equal(t, content, copyContent)
|
||||
@@ -113,6 +114,7 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
/*
|
||||
Archive structure.
|
||||
@@ -23,8 +25,8 @@ func TestUnzipFile(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1", "2.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1", "2.txt"))
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
)
|
||||
|
||||
func TestParseECREndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGenerateGo119CompatibleKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
seed string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/pkg/schedule"
|
||||
|
||||
chserver "github.com/jpillora/chisel/server"
|
||||
"github.com/jpillora/chisel/share/ccrypto"
|
||||
@@ -233,23 +234,13 @@ func (service *Service) startTunnelVerificationLoop() {
|
||||
Float64("check_interval_seconds", tunnelCleanupInterval.Seconds()).
|
||||
Msg("starting tunnel management process")
|
||||
|
||||
ticker := time.NewTicker(tunnelCleanupInterval)
|
||||
schedule.RunOnInterval(service.shutdownCtx, tunnelCleanupInterval, service.checkTunnels, func() {
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.checkTunnels()
|
||||
case <-service.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
|
||||
ticker.Stop()
|
||||
return
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// checkTunnels finds the first tunnel that has not had any activity recently
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -19,6 +18,7 @@ func init() {
|
||||
}
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
EdgeID: "test-edge-id",
|
||||
@@ -26,7 +26,7 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
UserTrusted: true,
|
||||
}
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
@@ -54,6 +54,6 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
require.Error(t, s.pingAgent(endpoint.ID))
|
||||
require.NoError(t, srv.Shutdown(context.Background()))
|
||||
require.NoError(t, srv.Shutdown(t.Context()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func (s *testStore) Settings() dataservices.SettingsService {
|
||||
}
|
||||
|
||||
func TestGetUnusedPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
||||
|
||||
@@ -152,11 +152,11 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
displayDeprecationWarnings(flags)
|
||||
|
||||
if err := validateEndpointURL(*flags.EndpointURL); err != nil {
|
||||
if err := ValidateEndpointURL(*flags.EndpointURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
||||
if err := ValidateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
func ValidateEndpointURL(endpointURL string) error {
|
||||
if endpointURL == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func validateEndpointURL(endpointURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnapshotInterval(snapshotInterval string) error {
|
||||
func ValidateSnapshotInterval(snapshotInterval string) error {
|
||||
if snapshotInterval == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
@@ -51,7 +52,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
@@ -174,10 +174,6 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
}
|
||||
|
||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager()
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
return apikey.NewAPIKeyService(datastore.APIKeyRepository(), datastore.User())
|
||||
}
|
||||
@@ -216,13 +212,12 @@ func initSnapshotService(
|
||||
dataStore dataservices.DataStore,
|
||||
dockerClientFactory *dockerclient.ClientFactory,
|
||||
kubernetesClientFactory *kubecli.ClientFactory,
|
||||
shutdownCtx context.Context,
|
||||
pendingActionsService *pendingactions.PendingActionsService,
|
||||
) (portainer.SnapshotService, error) {
|
||||
dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory)
|
||||
kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory)
|
||||
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, shutdownCtx, pendingActionsService)
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, pendingActionsService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -338,8 +333,7 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
|
||||
|
||||
if flags.FeatureFlags != nil {
|
||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||
@@ -350,7 +344,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
// validate if the trusted origins are valid urls
|
||||
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
|
||||
if !validate.IsTrustedOrigin(origin) {
|
||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid trusted origin: must be scheme://host or scheme://host:port (e.g. https://example.com)")
|
||||
}
|
||||
|
||||
trustedOrigins = append(trustedOrigins, origin)
|
||||
@@ -461,19 +455,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
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)
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
|
||||
snapshotService.Start()
|
||||
snapshotService.Start(shutdownCtx)
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
helmPackageManager := libhelm.NewHelmPackageManager()
|
||||
|
||||
applicationStatus := initStatus(instanceID)
|
||||
|
||||
@@ -539,10 +530,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
||||
}
|
||||
|
||||
platformService, err := platform.NewService(dataStore)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing platform service")
|
||||
}
|
||||
platformService := platform.NewService(dataStore)
|
||||
|
||||
upgradeService, err := upgrade.NewService(
|
||||
*flags.Assets,
|
||||
@@ -572,6 +560,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failure during post init migrations")
|
||||
}
|
||||
|
||||
if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return recoverStaleDeployingStacks(tx)
|
||||
}); err != nil {
|
||||
log.Info().Err(err).
|
||||
Msg("Error recovering stale deploying stacks")
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
@@ -604,7 +599,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
Scheduler: scheduler,
|
||||
ShutdownCtx: shutdownCtx,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
StackDeployer: stackDeployer,
|
||||
UpgradeService: upgradeService,
|
||||
@@ -626,7 +620,8 @@ func main() {
|
||||
logs.SetLoggingMode(*flags.LogMode)
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
server := buildServer(flags, shutdownCtx, shutdownTrigger)
|
||||
|
||||
log.Info().
|
||||
Str("version", portainer.APIVersion).
|
||||
@@ -638,8 +633,44 @@ func main() {
|
||||
Str("go_version", build.GoVersion).
|
||||
Msg("starting Portainer")
|
||||
|
||||
err := server.Start()
|
||||
err := server.Start(shutdownCtx)
|
||||
|
||||
log.Info().Err(err).Msg("HTTP server exited")
|
||||
}
|
||||
}
|
||||
|
||||
// recoverStaleDeployingStacks resets any stack that was left in the Deploying state
|
||||
// (e.g. because the server was restarted mid-deployment) to the Error state so the
|
||||
// user can retry.
|
||||
func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error {
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.Status == portainer.StackStatusDeploying
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
stack.Status = portainer.StackStatusError
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusError,
|
||||
Time: time.Now().Unix(),
|
||||
Message: "Deployment interrupted by server restart",
|
||||
})
|
||||
|
||||
if err := tx.Stack().Update(stack.ID, &stack); err != nil {
|
||||
log.Warn().Err(err).
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Unable to recover stale deploying stack")
|
||||
continue
|
||||
}
|
||||
log.Debug().
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("stack_name", stack.Name).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Recovered stale deploying stack to error state")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,8 +19,9 @@ func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||
}
|
||||
|
||||
func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
secretPath := path.Join(tempDir, secretFileName)
|
||||
secretPath := filesystem.JoinPaths(tempDir, secretFileName)
|
||||
|
||||
// first pointing to file that does not exist, gives nil hash (no encryption)
|
||||
encryptionKey := loadEncryptionSecretKey(secretPath)
|
||||
@@ -39,6 +41,7 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDBSecretPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
keyFilenameFlag string
|
||||
expected string
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
@@ -42,9 +42,9 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
@@ -141,15 +141,16 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
@@ -200,13 +201,14 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
@@ -257,13 +259,14 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1024 * 50)
|
||||
@@ -314,13 +317,14 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := randBytes(1034)
|
||||
@@ -385,6 +389,7 @@ func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) erro
|
||||
}
|
||||
|
||||
func Test_hasEncryptedHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCreateSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s = NewECDSAService("secret")
|
||||
|
||||
privKey, pubKey, err := s.GenerateKeyPair()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestService_Hash(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s = Service{}
|
||||
|
||||
type args struct {
|
||||
@@ -55,6 +56,7 @@ func TestService_Hash(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Service{}
|
||||
|
||||
hash, err := s.Hash("Passw0rd!")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCreateTLSConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// InsecureSkipVerify = false
|
||||
config := CreateTLSConfiguration(false)
|
||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||
@@ -22,6 +23,7 @@ func TestCreateTLSConfiguration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips := true
|
||||
|
||||
fipsCipherSuites := []uint16{
|
||||
@@ -42,6 +44,7 @@ func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
|
||||
require.NoError(t, err)
|
||||
@@ -59,6 +62,7 @@ func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
||||
t.Parallel()
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
|
||||
require.NoError(t, err)
|
||||
@@ -74,6 +78,7 @@ func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips := true
|
||||
|
||||
// Skipping TLS verifications cannot be done in FIPS mode
|
||||
|
||||
@@ -2,7 +2,6 @@ package boltdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test the specific scenarios mentioned in NeedsEncryptionMigration
|
||||
|
||||
// i.e.
|
||||
@@ -96,7 +96,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
|
||||
if tc.dbname == "both" {
|
||||
// Special case. If portainer.db and portainer.edb exist.
|
||||
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
||||
dbFile1 := filesystem.JoinPaths(connection.Path, DatabaseFileName)
|
||||
f, _ := os.Create(dbFile1)
|
||||
|
||||
err := f.Close()
|
||||
@@ -107,7 +107,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
||||
dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName)
|
||||
f, _ = os.Create(dbFile2)
|
||||
|
||||
err = f.Close()
|
||||
@@ -118,7 +118,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
} else if tc.dbname != "" {
|
||||
dbFile := path.Join(connection.Path, tc.dbname)
|
||||
dbFile := filesystem.JoinPaths(connection.Path, tc.dbname)
|
||||
f, _ := os.Create(dbFile)
|
||||
|
||||
err := f.Close()
|
||||
@@ -143,6 +143,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDBCompaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := &DbConnection{Path: t.TempDir()}
|
||||
|
||||
err := db.Open()
|
||||
|
||||
@@ -27,6 +27,7 @@ func secretToEncryptionKey(passphrase string) []byte {
|
||||
}
|
||||
|
||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
uuid := uuid.New()
|
||||
@@ -101,6 +102,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -142,6 +144,7 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -184,6 +187,7 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_NonceSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
|
||||
// the old way of creating and including the nonce
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type testStruct struct {
|
||||
}
|
||||
|
||||
func TestTxs(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn := DbConnection{Path: t.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
|
||||
connection, err := NewDatabase("boltdb", dbPath, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -51,6 +51,7 @@ func (m mockConnection) ConvertToKey(v int) []byte {
|
||||
return []byte(strconv.Itoa(v))
|
||||
}
|
||||
func TestReadAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := BaseDataService[testObject, int]{
|
||||
Bucket: "testBucket",
|
||||
Connection: mockConnection{store: make(map[int]testObject)},
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCustomTemplateCreate(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCustomTemplateCreateTx(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, false, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestUpdateRelation(t *testing.T) {
|
||||
t.Parallel()
|
||||
const endpointID = 1
|
||||
const edgeStackID1 = 1
|
||||
const edgeStackID2 = 2
|
||||
@@ -106,6 +107,7 @@ func TestUpdateRelation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
|
||||
t.Parallel()
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
@@ -125,6 +127,7 @@ func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEndpointRelations(t *testing.T) {
|
||||
t.Parallel()
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDeleteByEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
// Create Endpoint 1
|
||||
|
||||
@@ -27,10 +27,11 @@ type stackBuilder struct {
|
||||
}
|
||||
|
||||
func TestService_StackByWebhookID(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
b := stackBuilder{t: t, store: store}
|
||||
b.createNewStack(newGuidString(t))
|
||||
@@ -84,10 +85,11 @@ func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
|
||||
}
|
||||
|
||||
func Test_RefreshableStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
staticStack := portainer.Stack{ID: 1}
|
||||
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.AutoUpdateSettings{Webhook: "webhook"}}
|
||||
|
||||
@@ -3,6 +3,7 @@ package tests
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
@@ -10,9 +11,29 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type teamBuilder struct {
|
||||
t *testing.T
|
||||
count int
|
||||
store *datastore.Store
|
||||
}
|
||||
|
||||
func (b *teamBuilder) createNew(name string) *portainer.Team {
|
||||
b.count++
|
||||
team := &portainer.Team{
|
||||
ID: portainer.TeamID(b.count),
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err := b.store.Team().Create(team)
|
||||
assert.NoError(b.t, err)
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
func Test_teamByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("When store is empty should return ErrObjectNotFound", func(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
_, err := store.Team().TeamByName("name")
|
||||
require.ErrorIs(t, err, errors.ErrObjectNotFound)
|
||||
@@ -20,7 +41,7 @@ func Test_teamByName(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When there is no object with the same name should return ErrObjectNotFound", func(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
teamBuilder := teamBuilder{
|
||||
t: t,
|
||||
@@ -35,7 +56,7 @@ func Test_teamByName(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When there is an object with the same name should return the object", func(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
teamBuilder := teamBuilder{
|
||||
t: t,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type teamBuilder struct {
|
||||
t *testing.T
|
||||
count int
|
||||
store *datastore.Store
|
||||
}
|
||||
|
||||
func (b *teamBuilder) createNew(name string) *portainer.Team {
|
||||
b.count++
|
||||
team := &portainer.Team{
|
||||
ID: portainer.TeamID(b.count),
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err := b.store.Team().Create(team)
|
||||
assert.NoError(b.t, err)
|
||||
|
||||
return team
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStoreCreation(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
require.NotNil(t, store)
|
||||
|
||||
@@ -31,6 +32,7 @@ func TestStoreCreation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackup(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
backupFileName := store.backupFilename()
|
||||
t.Run("Backup should create "+backupFileName, func(t *testing.T) {
|
||||
@@ -52,6 +54,7 @@ func TestBackup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRestore(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
t.Run("Basic Restore", func(t *testing.T) {
|
||||
@@ -93,6 +96,7 @@ func TestRestore(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupDBFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
t.Run("creates backup file without managing connection state", func(t *testing.T) {
|
||||
@@ -122,6 +126,7 @@ func TestBackupDBFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupDBFileUsesCorrectPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
// TestStoreFull an eventually comprehensive set of tests for the Store.
|
||||
// The idea is what we write to the store, we should read back.
|
||||
func TestStoreFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, true, true)
|
||||
|
||||
testCases := map[string]func(t *testing.T){
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -174,6 +174,7 @@ func TestMigrateData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Rollback should restore upgrade after backup", func(t *testing.T) {
|
||||
version := "2.11"
|
||||
|
||||
@@ -324,7 +325,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
|
||||
|
||||
// Compare the result we got with the one we wanted.
|
||||
if diff := cmp.Diff(wantJSON, gotJSON); diff != "" {
|
||||
gotPath := filepath.Join(os.TempDir(), "portainer-migrator-test-fail.json")
|
||||
gotPath := filesystem.JoinPaths(os.TempDir(), "portainer-migrator-test-fail.json")
|
||||
err = os.WriteFile(
|
||||
gotPath,
|
||||
gotJSON,
|
||||
|
||||
@@ -26,6 +26,7 @@ func setup(store *Store) error {
|
||||
}
|
||||
|
||||
func TestMigrateSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, false, true)
|
||||
|
||||
err := setup(store)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMigrateStackEntryPoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := MustNewTestStore(t, false, true)
|
||||
|
||||
stackService := store.Stack()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
|
||||
t.Parallel()
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMigrateRegistryAccessSASecrets_2_40_0(t *testing.T) {
|
||||
t.Parallel()
|
||||
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
|
||||
err := conn.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -14,6 +14,7 @@ type cleanNAPWithOverridePolicies struct {
|
||||
}
|
||||
|
||||
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
|
||||
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMigrateGPUs(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/containers/json") {
|
||||
containerSummary := []container.Summary{{ID: "container1"}}
|
||||
@@ -79,6 +80,7 @@ func TestMigrateGPUs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
existingPendingActions []*portainer.PendingAction
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -808,6 +808,7 @@
|
||||
"AutoUpdate": null,
|
||||
"CreatedBy": "",
|
||||
"CreationDate": 0,
|
||||
"DeploymentStartStatus": 0,
|
||||
"EndpointId": 1,
|
||||
"EntryPoint": "docker/alpine37-compose.yml",
|
||||
"Env": [],
|
||||
@@ -830,6 +831,7 @@
|
||||
"AutoUpdate": null,
|
||||
"CreatedBy": "",
|
||||
"CreationDate": 0,
|
||||
"DeploymentStartStatus": 0,
|
||||
"EndpointId": 1,
|
||||
"EntryPoint": "docker-compose.yml",
|
||||
"Env": [],
|
||||
@@ -852,6 +854,7 @@
|
||||
"AutoUpdate": null,
|
||||
"CreatedBy": "",
|
||||
"CreationDate": 0,
|
||||
"DeploymentStartStatus": 0,
|
||||
"EndpointId": 1,
|
||||
"EntryPoint": "docker-compose.yml",
|
||||
"Env": [],
|
||||
@@ -944,7 +947,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHttpClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
// Valid TLS configuration
|
||||
|
||||
@@ -21,14 +21,12 @@ import (
|
||||
type ContainerService struct {
|
||||
factory *dockerclient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
sr *serviceRestore
|
||||
}
|
||||
|
||||
func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *ContainerService {
|
||||
return &ContainerService{
|
||||
factory: factory,
|
||||
dataStore: dataStore,
|
||||
sr: &serviceRestore{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +139,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
initialNetwork.EndpointsConfig[name] = network
|
||||
}
|
||||
}
|
||||
c.sr.enable()
|
||||
defer c.sr.close()
|
||||
defer c.sr.restore()
|
||||
|
||||
c.sr.push(func() {
|
||||
restore := true
|
||||
|
||||
defer func() {
|
||||
if !restore {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("container_id", containerId).Str("container", container.Name).Msg("restoring the container")
|
||||
if err := cli.ContainerRename(ctx, containerId, container.Name); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to rename container")
|
||||
@@ -160,7 +161,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
if err := cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{}); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to start container")
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
|
||||
|
||||
@@ -179,8 +180,15 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
}
|
||||
|
||||
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create container error")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if !restore {
|
||||
return
|
||||
}
|
||||
|
||||
c.sr.push(func() {
|
||||
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
|
||||
|
||||
if err := cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{}); err != nil {
|
||||
@@ -190,11 +198,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
if err := cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{}); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to remove container")
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create container error")
|
||||
}
|
||||
}()
|
||||
|
||||
newContainerId := create.ID
|
||||
|
||||
@@ -224,7 +228,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
|
||||
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
|
||||
|
||||
c.sr.disable()
|
||||
restore = false
|
||||
|
||||
newContainer, _, err := cli.ContainerInspectWithRaw(ctx, newContainerId, true)
|
||||
if err != nil {
|
||||
@@ -233,51 +237,3 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
|
||||
return &newContainer, nil
|
||||
}
|
||||
|
||||
type serviceRestore struct {
|
||||
restoreC chan struct{}
|
||||
fs []func()
|
||||
}
|
||||
|
||||
func (sr *serviceRestore) enable() {
|
||||
sr.restoreC = make(chan struct{}, 1)
|
||||
sr.fs = make([]func(), 0)
|
||||
sr.restoreC <- struct{}{}
|
||||
}
|
||||
|
||||
func (sr *serviceRestore) disable() {
|
||||
select {
|
||||
case <-sr.restoreC:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *serviceRestore) push(f func()) {
|
||||
sr.fs = append(sr.fs, f)
|
||||
}
|
||||
|
||||
func (sr *serviceRestore) restore() {
|
||||
select {
|
||||
case <-sr.restoreC:
|
||||
l := len(sr.fs)
|
||||
if l > 0 {
|
||||
for i := l - 1; i >= 0; i-- {
|
||||
sr.fs[i]()
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *serviceRestore) close() {
|
||||
if sr == nil || sr.restoreC == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-sr.restoreC:
|
||||
default:
|
||||
}
|
||||
|
||||
close(sr.restoreC)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestApplyVersionConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
initialNet := network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
"key1": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestParseLocalImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test with a regular image
|
||||
|
||||
img, err := ParseLocalImage(image.InspectResponse{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestImageParser(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// portainer/portainer-ee
|
||||
@@ -62,6 +63,7 @@ func TestImageParser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateParsedImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFindBestMatchNeedAuthRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("", func(t *testing.T) {
|
||||
|
||||
@@ -89,11 +89,11 @@ func FigureOut(statuses []Status) Status {
|
||||
return Preparing
|
||||
}
|
||||
|
||||
if contains(statuses, Outdated) {
|
||||
if slices.Contains(statuses, Outdated) {
|
||||
return Outdated
|
||||
} else if contains(statuses, Processing) {
|
||||
} else if slices.Contains(statuses, Processing) {
|
||||
return Processing
|
||||
} else if contains(statuses, Error) {
|
||||
} else if slices.Contains(statuses, Error) {
|
||||
return Error
|
||||
}
|
||||
|
||||
@@ -275,14 +275,6 @@ func EvictImageStatus(resourceID string) {
|
||||
statusCache.Delete(resourceID)
|
||||
}
|
||||
|
||||
func contains(statuses []Status, status Status) bool {
|
||||
if len(statuses) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.Contains(statuses, status)
|
||||
}
|
||||
|
||||
func allMatch(statuses []Status, status Status) bool {
|
||||
if len(statuses) == 0 {
|
||||
return false
|
||||
|
||||
@@ -25,6 +25,7 @@ func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID str
|
||||
}
|
||||
|
||||
func TestCalculateContainerStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers - using enough containers to test concurrent processing
|
||||
@@ -78,7 +79,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
require.NoError(t, err, "failed to calculate container stats")
|
||||
duration := time.Since(startTime)
|
||||
|
||||
@@ -105,6 +106,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers
|
||||
@@ -118,7 +120,7 @@ func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
|
||||
|
||||
// Call the function
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
|
||||
// Assert that an error was returned
|
||||
require.Error(t, err, "should return error when all containers fail to inspect")
|
||||
@@ -140,6 +142,7 @@ func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetContainerStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
state *container.State
|
||||
@@ -232,6 +235,7 @@ func TestGetContainerStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsForSwarm(t *testing.T) {
|
||||
t.Parallel()
|
||||
containers := []container.Summary{
|
||||
{State: "running"},
|
||||
{State: "running", Status: "Up 5 minutes (healthy)"},
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/testhelpers"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -26,7 +25,7 @@ const composedContainerName = "compose_wrapper_test"
|
||||
func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
|
||||
dir := t.TempDir()
|
||||
composeFileName := "compose_wrapper_test.yml"
|
||||
f, err := os.Create(filepath.Join(dir, composeFileName))
|
||||
f, err := os.Create(filesystem.JoinPaths(dir, composeFileName))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.WriteString(composeFile)
|
||||
@@ -42,6 +41,7 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
|
||||
}
|
||||
|
||||
func Test_UpAndDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
testhelpers.IntegrationTest(t)
|
||||
|
||||
stack, endpoint := setup(t)
|
||||
@@ -50,9 +50,7 @@ func Test_UpAndDown(t *testing.T) {
|
||||
|
||||
w := NewComposeStackManager(deployer, nil, nil)
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
if err := w.Up(ctx, stack, endpoint, portainer.ComposeUpOptions{}); err != nil {
|
||||
if err := w.Up(t.Context(), stack, endpoint, portainer.ComposeUpOptions{}); err != nil {
|
||||
t.Fatalf("Error calling docker-compose up: %s", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ func Test_UpAndDown(t *testing.T) {
|
||||
t.Fatal("container should exist")
|
||||
}
|
||||
|
||||
if err := w.Down(ctx, stack, endpoint); err != nil {
|
||||
if err := w.Down(t.Context(), stack, endpoint); err != nil {
|
||||
t.Fatalf("Error calling docker-compose down: %s", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ package exec
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_createEnvFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
@@ -56,9 +56,9 @@ func Test_createEnvFile(t *testing.T) {
|
||||
result, _ := createEnvFile(tt.stack)
|
||||
|
||||
if tt.expected != "" {
|
||||
assert.Equal(t, filepath.Join(tt.stack.ProjectPath, "stack.env"), result)
|
||||
assert.Equal(t, filesystem.JoinPaths(tt.stack.ProjectPath, "stack.env"), result)
|
||||
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
f, _ := os.Open(filesystem.JoinPaths(dir, "stack.env"))
|
||||
content, _ := io.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expected, string(content))
|
||||
@@ -70,8 +70,9 @@ func Test_createEnvFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(path.Join(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
stack := &portainer.Stack{
|
||||
@@ -82,11 +83,11 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
},
|
||||
}
|
||||
result, err := createEnvFile(stack)
|
||||
assert.Equal(t, filepath.Join(stack.ProjectPath, "stack.env"), result)
|
||||
assert.Equal(t, filesystem.JoinPaths(stack.ProjectPath, "stack.env"), result)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, path.Join(dir, "stack.env"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(dir, "stack.env"))
|
||||
|
||||
f, err := os.Open(path.Join(dir, "stack.env"))
|
||||
f, err := os.Open(filesystem.JoinPaths(dir, "stack.env"))
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package exectest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
@@ -13,14 +15,14 @@ func NewKubernetesDeployer() *kubernetesMockDeployer {
|
||||
return &kubernetesMockDeployer{}
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
func (deployer *kubernetesMockDeployer) Deploy(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
func (deployer *kubernetesMockDeployer) Remove(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
func (deployer *kubernetesMockDeployer) Restart(_ context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -76,16 +76,16 @@ func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *
|
||||
}
|
||||
|
||||
// Deploy upserts Kubernetes resources defined in manifest(s)
|
||||
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command("apply", userID, endpoint, resources, namespace)
|
||||
func (deployer *KubernetesDeployer) Deploy(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command(ctx, "apply", userID, endpoint, resources, namespace)
|
||||
}
|
||||
|
||||
// Remove deletes Kubernetes resources defined in manifest(s)
|
||||
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command("delete", userID, endpoint, resources, namespace)
|
||||
func (deployer *KubernetesDeployer) Remove(ctx context.Context, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command(ctx, "delete", userID, endpoint, resources, namespace)
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
func (deployer *KubernetesDeployer) command(ctx context.Context, operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed generating a user token")
|
||||
@@ -120,7 +120,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
return "", errors.Errorf("unsupported operation: %s", operation)
|
||||
}
|
||||
|
||||
output, err := operationFunc(context.Background(), resources)
|
||||
output, err := operationFunc(ctx, resources)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func testExecuteKubectlOperation(client *mockKubectlClient, operation string, ma
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
applyFunc: func(ctx context.Context, files []string) error {
|
||||
@@ -74,6 +75,7 @@ func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
expectedErr := errors.New("kubectl apply failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
@@ -93,6 +95,7 @@ func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
deleteFunc: func(ctx context.Context, files []string) error {
|
||||
@@ -110,6 +113,7 @@ func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
expectedErr := errors.New("kubectl delete failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
@@ -129,6 +133,7 @@ func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
|
||||
@@ -146,6 +151,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
expectedErr := errors.New("kubectl rollout restart failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
@@ -165,6 +171,7 @@ func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := &mockKubectlClient{}
|
||||
|
||||
err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
|
||||
|
||||
@@ -2,6 +2,7 @@ package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -53,7 +54,7 @@ func NewSwarmStackManager(
|
||||
}
|
||||
|
||||
// Login executes the docker login command against a list of registries (including DockerHub).
|
||||
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) error {
|
||||
func (manager *SwarmStackManager) Login(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -67,7 +68,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
||||
}
|
||||
|
||||
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
|
||||
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
|
||||
if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
@@ -80,7 +81,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
||||
}
|
||||
|
||||
// Logout executes the docker logout command.
|
||||
func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
func (manager *SwarmStackManager) Logout(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,11 +89,11 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
|
||||
args = append(args, "logout")
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
|
||||
}
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
|
||||
func (manager *SwarmStackManager) Deploy(ctx context.Context, stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
|
||||
filePaths := stackutils.GetStackFilePaths(stack, true)
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
@@ -117,11 +118,11 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pul
|
||||
env = append(env, envvar.Name+"="+envvar.Value)
|
||||
}
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, env, stack.ProjectPath)
|
||||
}
|
||||
|
||||
// Remove executes the docker stack rm command.
|
||||
func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
func (manager *SwarmStackManager) Remove(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -129,14 +130,16 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
|
||||
|
||||
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
|
||||
}
|
||||
|
||||
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
|
||||
func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error {
|
||||
var stderr bytes.Buffer
|
||||
var stdout bytes.Buffer
|
||||
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if workingDir != "" {
|
||||
cmd.Dir = workingDir
|
||||
@@ -148,7 +151,15 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.New(stderr.String())
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestConfigFilePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := []string{"stack", "deploy", "--with-registry-auth"}
|
||||
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
|
||||
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
|
||||
@@ -17,6 +19,7 @@ func TestConfigFilePaths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrepareDockerCommandAndArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
binaryPath := "/test/dist"
|
||||
configPath := "/test/config"
|
||||
manager := &SwarmStackManager{
|
||||
@@ -41,3 +44,43 @@ func TestPrepareDockerCommandAndArgs(t *testing.T) {
|
||||
require.Equal(t, expectedCommand, command)
|
||||
require.Equal(t, expectedArgs, args)
|
||||
}
|
||||
|
||||
func TestRunCommandAndCaptureStdErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("should return nil on successful command", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "echo", []string{"hello"}, nil, "")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should capture stderr on failure", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stderr error' >&2; exit 1"}, nil, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "stderr error")
|
||||
})
|
||||
|
||||
t.Run("should fall back to stdout when stderr is empty", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout error'; exit 1"}, nil, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "stdout error")
|
||||
})
|
||||
|
||||
t.Run("should fall back to exec error when both are empty", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "exit 1"}, nil, "")
|
||||
require.Error(t, err)
|
||||
assert.NotEmpty(t, err.Error())
|
||||
assert.Contains(t, err.Error(), "exit status 1")
|
||||
})
|
||||
|
||||
t.Run("should prefer stderr over stdout", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout msg'; echo 'stderr msg' >&2; exit 1"}, nil, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "stderr msg")
|
||||
assert.NotContains(t, err.Error(), "stdout msg")
|
||||
})
|
||||
|
||||
t.Run("should return error for non-existent command", func(t *testing.T) {
|
||||
err := runCommandAndCaptureStdErr(context.Background(), "nonexistent-cmd-12345", nil, nil, "")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -11,47 +9,52 @@ import (
|
||||
)
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
|
||||
err := os.WriteFile(JoinPaths(tmpdir, "origin"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
|
||||
err = copyFile(JoinPaths(tmpdir, "origin"), JoinPaths(tmpdir, "copy"))
|
||||
require.NoError(t, err)
|
||||
|
||||
copyContent, err := os.ReadFile(path.Join(tmpdir, "copy"))
|
||||
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "copy"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
destination := t.TempDir()
|
||||
err := CopyDir("./testdata/copy_test", destination, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
|
||||
t.Parallel()
|
||||
destination := t.TempDir()
|
||||
err := CopyDir("./testdata/copy_test", destination, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "dir", "inner"))
|
||||
assert.FileExists(t, JoinPaths(destination, "outer"))
|
||||
assert.FileExists(t, JoinPaths(destination, "dir", ".dotfile"))
|
||||
assert.FileExists(t, JoinPaths(destination, "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
err := CopyPath("does-not-exists", tmpdir)
|
||||
require.NoError(t, err)
|
||||
@@ -60,36 +63,39 @@ func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldCopyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
|
||||
err := os.WriteFile(path.Join(tmpdir, "file"), content, 0600)
|
||||
err := os.WriteFile(JoinPaths(tmpdir, "file"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
|
||||
err = os.MkdirAll(JoinPaths(tmpdir, "backup"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
|
||||
err = CopyPath(JoinPaths(tmpdir, "file"), JoinPaths(tmpdir, "backup"))
|
||||
require.NoError(t, err)
|
||||
|
||||
copyContent, err := os.ReadFile(path.Join(tmpdir, "backup", "file"))
|
||||
copyContent, err := os.ReadFile(JoinPaths(tmpdir, "backup", "file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldCopyDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
destination := t.TempDir()
|
||||
err := CopyPath("./testdata/copy_test", destination)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func TestCopyPathPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "myfile")
|
||||
p := JoinPaths(dir, "myfile")
|
||||
|
||||
err := os.WriteFile(p, []byte("contents"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -91,7 +91,7 @@ func JoinPaths(trustedRoot string, untrustedPaths ...string) string {
|
||||
trustedRoot = "."
|
||||
}
|
||||
|
||||
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...))
|
||||
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) //nolint:forbidigo
|
||||
|
||||
// avoid setting a volume name from the untrusted paths
|
||||
vnp := filepath.VolumeName(p)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -12,20 +11,24 @@ import (
|
||||
)
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileNotExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
t.Parallel()
|
||||
testHelperFileExists_fileExists(t, FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
testHelperFileExists_fileNotExists(t, FileExists)
|
||||
}
|
||||
|
||||
@@ -45,7 +48,7 @@ func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bo
|
||||
}
|
||||
|
||||
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
filePath := path.Join(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
|
||||
filePath := JoinPaths(t.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
|
||||
|
||||
err := os.RemoveAll(filePath)
|
||||
require.NoError(t, err, "RemoveAll should not fail")
|
||||
|
||||
@@ -3,6 +3,7 @@ package filesystem
|
||||
import "testing"
|
||||
|
||||
func TestJoinPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
var ts = []struct {
|
||||
trusted string
|
||||
untrusted string
|
||||
|
||||
@@ -2,7 +2,6 @@ package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
var content = []byte("content")
|
||||
|
||||
func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceDir := "missing"
|
||||
destinationDir := t.TempDir()
|
||||
file1 := addFile(t, destinationDir, "dir", "file")
|
||||
@@ -24,6 +24,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceDir := t.TempDir()
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
@@ -40,6 +41,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceDir := t.TempDir()
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
@@ -56,31 +58,32 @@ func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
sourceDir := path.Join(tmp, "source")
|
||||
sourceDir := JoinPaths(tmp, "source")
|
||||
err := os.Mkdir(sourceDir, 0766)
|
||||
require.NoError(t, err)
|
||||
|
||||
file1 := addFile(t, sourceDir, "dir", "file")
|
||||
file2 := addFile(t, sourceDir, "file")
|
||||
destinationDir := path.Join(tmp, "destination")
|
||||
destinationDir := JoinPaths(tmp, "destination")
|
||||
|
||||
err = MoveDirectory(sourceDir, destinationDir, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoFileExists(t, file1, "source dir contents should be moved")
|
||||
assert.NoFileExists(t, file2, "source dir contents should be moved")
|
||||
assertFileContent(t, path.Join(destinationDir, "file"))
|
||||
assertFileContent(t, path.Join(destinationDir, "dir", "file"))
|
||||
assertFileContent(t, JoinPaths(destinationDir, "file"))
|
||||
assertFileContent(t, JoinPaths(destinationDir, "dir", "file"))
|
||||
}
|
||||
|
||||
func addFile(t *testing.T, fileParts ...string) (filepath string) {
|
||||
if len(fileParts) > 2 {
|
||||
dir := path.Join(fileParts[:len(fileParts)-1]...)
|
||||
dir := JoinPaths(fileParts[0], fileParts[1:len(fileParts)-1]...)
|
||||
err := os.MkdirAll(dir, 0766)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
p := path.Join(fileParts...)
|
||||
p := JoinPaths(fileParts[0], fileParts[1:]...)
|
||||
err := os.WriteFile(p, content, 0766)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createService(t *testing.T) *Service {
|
||||
dataStorePath := path.Join(t.TempDir(), t.Name())
|
||||
dataStorePath := JoinPaths(t.TempDir(), t.Name())
|
||||
|
||||
service, err := NewService(dataStorePath, "")
|
||||
require.NoError(t, err, "NewService should not fail")
|
||||
|
||||
@@ -3,6 +3,7 @@ package filesystem
|
||||
import "testing"
|
||||
|
||||
func TestJoinPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
var ts = []struct {
|
||||
trusted string
|
||||
untrusted string
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
@@ -80,6 +81,7 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
@@ -180,6 +182,7 @@ func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -204,6 +207,7 @@ func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsInConfigDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := func(dirEntry DirEntry, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
@@ -225,6 +229,7 @@ func TestIsInConfigDir(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShouldIncludeDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -10,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
tmpFilePath := path.Join(tmpDir, "dummy")
|
||||
tmpFilePath := JoinPaths(tmpDir, "dummy")
|
||||
|
||||
content := []byte("content")
|
||||
err := WriteToFile(tmpFilePath, content)
|
||||
@@ -22,8 +22,9 @@ func Test_WriteFile_CanStoreContentInANewFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
tmpFilePath := path.Join(tmpDir, "dummy")
|
||||
tmpFilePath := JoinPaths(tmpDir, "dummy")
|
||||
|
||||
err := WriteToFile(tmpFilePath, []byte("content"))
|
||||
require.NoError(t, err)
|
||||
@@ -37,8 +38,9 @@ func Test_WriteFile_CanOverwriteExistingFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_WriteFile_CanWriteANestedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpDir := t.TempDir()
|
||||
tmpFilePath := path.Join(tmpDir, "dir", "sub-dir", "dummy")
|
||||
tmpFilePath := JoinPaths(tmpDir, "dir", "sub-dir", "dummy")
|
||||
|
||||
content := []byte("content")
|
||||
err := WriteToFile(tmpFilePath, content)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
@@ -18,10 +17,11 @@ import (
|
||||
const privateAzureRepoURL = "https://portainer.visualstudio.com/gitops-test/_git/gitops-test"
|
||||
|
||||
func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService(context.TODO())
|
||||
service := NewService(t.Context())
|
||||
|
||||
type args struct {
|
||||
repositoryURLFormat string
|
||||
@@ -60,6 +60,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
dst := t.TempDir()
|
||||
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
||||
err := service.CloneRepository(
|
||||
t.Context(),
|
||||
dst,
|
||||
repositoryUrl,
|
||||
tt.args.referenceName,
|
||||
@@ -68,20 +69,22 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService(context.TODO())
|
||||
service := NewService(t.Context())
|
||||
|
||||
dst := t.TempDir()
|
||||
|
||||
err := service.CloneRepository(
|
||||
t.Context(),
|
||||
dst,
|
||||
privateAzureRepoURL,
|
||||
"refs/heads/main",
|
||||
@@ -90,16 +93,18 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
|
||||
}
|
||||
|
||||
func TestService_LatestCommitID_Azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService(context.TODO())
|
||||
service := NewService(t.Context())
|
||||
|
||||
id, err := service.LatestCommitID(
|
||||
t.Context(),
|
||||
privateAzureRepoURL,
|
||||
"refs/heads/main",
|
||||
"",
|
||||
@@ -111,13 +116,15 @@ func TestService_LatestCommitID_Azure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ListRefs_Azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||
service := NewService(context.TODO())
|
||||
service := NewService(t.Context())
|
||||
|
||||
refs, err := service.ListRefs(
|
||||
t.Context(),
|
||||
privateAzureRepoURL,
|
||||
username,
|
||||
accessToken,
|
||||
@@ -129,23 +136,25 @@ func TestService_ListRefs_Azure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ListRefs_Azure_Concurrently(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
|
||||
|
||||
go func() {
|
||||
_, _ = service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
|
||||
_, _ = service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
|
||||
}()
|
||||
|
||||
_, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false)
|
||||
_, err := service.ListRefs(t.Context(), privateAzureRepoURL, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
func TestService_ListFiles_Azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
type args struct {
|
||||
@@ -162,7 +171,7 @@ func TestService_ListFiles_Azure(t *testing.T) {
|
||||
matchedCount int
|
||||
}
|
||||
|
||||
service := newService(context.TODO(), 0, 0)
|
||||
service := newService(t.Context(), 0, 0)
|
||||
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||
|
||||
@@ -273,6 +282,7 @@ func TestService_ListFiles_Azure(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, err := service.ListFiles(
|
||||
t.Context(),
|
||||
tt.args.repositoryUrl,
|
||||
tt.args.referenceName,
|
||||
tt.args.username,
|
||||
@@ -299,14 +309,16 @@ func TestService_ListFiles_Azure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME")
|
||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
|
||||
|
||||
go func() {
|
||||
_, _ = service.ListFiles(
|
||||
t.Context(),
|
||||
privateAzureRepoURL,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -319,6 +331,7 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) {
|
||||
}()
|
||||
|
||||
_, err := service.ListFiles(
|
||||
t.Context(),
|
||||
privateAzureRepoURL,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_buildDownloadUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewAzureClient()
|
||||
u, err := a.buildDownloadUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
@@ -39,6 +40,7 @@ func Test_buildDownloadUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_buildRootItemUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewAzureClient()
|
||||
u, err := a.buildRootItemUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
@@ -56,6 +58,7 @@ func Test_buildRootItemUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_buildRefsUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewAzureClient()
|
||||
u, err := a.buildRefsUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
@@ -73,6 +76,7 @@ func Test_buildRefsUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_buildTreeUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewAzureClient()
|
||||
u, err := a.buildTreeUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
@@ -90,6 +94,7 @@ func Test_buildTreeUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_parseAzureUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
url string
|
||||
}
|
||||
@@ -205,6 +210,7 @@ func Test_parseAzureUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_isAzureUrl(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
s string
|
||||
}
|
||||
@@ -243,6 +249,7 @@ func Test_isAzureUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
type args struct {
|
||||
@@ -311,7 +318,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
Password: tt.args.password,
|
||||
}
|
||||
}
|
||||
_, err := a.downloadZipFromAzureDevOps(context.Background(), option)
|
||||
_, err := a.downloadZipFromAzureDevOps(t.Context(), option)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.want, zipRequestAuth)
|
||||
})
|
||||
@@ -319,6 +326,7 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_azureDownloader_latestCommitID(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -367,7 +375,7 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id, err := a.LatestCommitID(context.Background(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{})
|
||||
id, err := a.LatestCommitID(t.Context(), tt.args.repositoryUrl, tt.args.referenceName, &git.ListOptions{})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -399,6 +407,7 @@ func (t *testRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptio
|
||||
}
|
||||
|
||||
func Test_cloneRepository_azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
@@ -427,7 +436,7 @@ func Test_cloneRepository_azure(t *testing.T) {
|
||||
git := &testRepoManager{}
|
||||
|
||||
s := &Service{azure: azure, git: git}
|
||||
err := s.CloneRepository("", tt.url, "", "", "", false)
|
||||
err := s.CloneRepository(t.Context(), "", tt.url, "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// if azure API is called, git isn't and vice versa
|
||||
@@ -438,6 +447,7 @@ func Test_cloneRepository_azure(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_listRefs_azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
client := NewAzureClient()
|
||||
@@ -517,7 +527,7 @@ func Test_listRefs_azure(t *testing.T) {
|
||||
Password: tt.args.password,
|
||||
}
|
||||
}
|
||||
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
|
||||
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
|
||||
if tt.expect.err == nil {
|
||||
require.NoError(t, err)
|
||||
if tt.expect.refsCount > 0 {
|
||||
@@ -532,6 +542,7 @@ func Test_listRefs_azure(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_listFiles_azure(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
client := NewAzureClient()
|
||||
@@ -634,7 +645,7 @@ func Test_listFiles_azure(t *testing.T) {
|
||||
Password: tt.args.password,
|
||||
}
|
||||
}
|
||||
paths, err := client.ListFiles(context.TODO(), false, option)
|
||||
paths, err := client.ListFiles(t.Context(), false, option)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
@@ -23,7 +25,7 @@ type CloneOptions struct {
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
}
|
||||
|
||||
func CloneWithBackup(gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) {
|
||||
func CloneWithBackup(ctx context.Context, gitService portainer.GitService, fileService portainer.FileService, options CloneOptions) (clean func(), err error) {
|
||||
backupProjectPath := options.ProjectPath + "-old"
|
||||
cleanUp := false
|
||||
cleanFn := func() {
|
||||
@@ -43,6 +45,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
|
||||
cleanUp = true
|
||||
|
||||
if err := gitService.CloneRepository(
|
||||
ctx,
|
||||
options.ProjectPath,
|
||||
options.URL,
|
||||
options.ReferenceName,
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
func GetCredentials(auth *gittypes.GitAuthentication) (string, string, error) {
|
||||
func GetCredentials(auth *gittypes.GitAuthentication) (string, string) {
|
||||
if auth == nil {
|
||||
return "", "", nil
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return auth.Username, auth.Password, nil
|
||||
return auth.Username, auth.Password
|
||||
}
|
||||
|
||||
@@ -3,20 +3,39 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
|
||||
// symlink traversal attacks from untrusted git repositories
|
||||
type noSymlinkFS struct {
|
||||
billy.Filesystem
|
||||
}
|
||||
|
||||
func (fs noSymlinkFS) Symlink(_, _ string) error {
|
||||
return gittypes.ErrSymlinkDetected
|
||||
}
|
||||
|
||||
// NewNoSymlinkFS wraps fs and rejects any symlink creation
|
||||
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
|
||||
return noSymlinkFS{fs}
|
||||
}
|
||||
|
||||
type gitClient struct {
|
||||
preserveGitDirectory bool
|
||||
}
|
||||
@@ -28,19 +47,25 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
||||
}
|
||||
|
||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, opt)
|
||||
wt := NewNoSymlinkFS(osfs.New(dst))
|
||||
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
|
||||
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
|
||||
|
||||
_, err := git.CloneContext(ctx, storer, wt, opt)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "failed to clone git repository")
|
||||
}
|
||||
|
||||
if !c.preserveGitDirectory {
|
||||
err := os.RemoveAll(filepath.Join(dst, ".git"))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
||||
}
|
||||
if c.preserveGitDirectory {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
|
||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -57,6 +82,7 @@ func (c *gitClient) LatestCommitID(ctx context.Context, repositoryUrl, reference
|
||||
if err.Error() == "authentication required" {
|
||||
return "", gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return "", errors.Wrap(err, "failed to list repository refs")
|
||||
}
|
||||
|
||||
@@ -93,6 +119,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
|
||||
if ref.Name().String() == "HEAD" {
|
||||
continue
|
||||
}
|
||||
|
||||
ret = append(ret, ref.Name().String())
|
||||
}
|
||||
|
||||
@@ -143,10 +170,11 @@ func (c *gitClient) ListFiles(ctx context.Context, dirOnly bool, opt *git.CloneO
|
||||
|
||||
func checkGitError(err error) error {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "repository not found" {
|
||||
if strings.Contains(errMsg, "repository not found") {
|
||||
return gittypes.ErrIncorrectRepositoryURL
|
||||
} else if errMsg == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -19,16 +18,18 @@ const (
|
||||
)
|
||||
|
||||
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), 0, 0)
|
||||
service := newService(t.Context(), 0, 0)
|
||||
|
||||
dst := t.TempDir()
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
err := service.CloneRepository(
|
||||
t.Context(),
|
||||
dst,
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
@@ -37,18 +38,20 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(dst, "README.md"))
|
||||
}
|
||||
|
||||
func TestService_LatestCommitID_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), 0, 0)
|
||||
service := newService(t.Context(), 0, 0)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
id, err := service.LatestCommitID(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -60,37 +63,40 @@ func TestService_LatestCommitID_GitHub(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ListRefs_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), 0, 0)
|
||||
service := newService(t.Context(), 0, 0)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
}
|
||||
|
||||
func TestService_ListRefs_Github_Concurrently(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
go func() {
|
||||
_, _ = service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
_, _ = service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
}()
|
||||
|
||||
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
func TestService_ListFiles_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
type args struct {
|
||||
@@ -106,7 +112,7 @@ func TestService_ListFiles_GitHub(t *testing.T) {
|
||||
err error
|
||||
matchedCount int
|
||||
}
|
||||
service := newService(context.TODO(), 0, 0)
|
||||
service := newService(t.Context(), 0, 0)
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
|
||||
@@ -217,6 +223,7 @@ func TestService_ListFiles_GitHub(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, err := service.ListFiles(
|
||||
t.Context(),
|
||||
tt.args.repositoryUrl,
|
||||
tt.args.referenceName,
|
||||
tt.args.username,
|
||||
@@ -242,15 +249,17 @@ func TestService_ListFiles_GitHub(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_ListFiles_Github_Concurrently(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond)
|
||||
service := newService(t.Context(), repositoryCacheSize, 200*time.Millisecond)
|
||||
|
||||
go func() {
|
||||
_, _ = service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -263,6 +272,7 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
|
||||
}()
|
||||
|
||||
_, err := service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -278,17 +288,19 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_purgeCache_Github(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := NewService(context.TODO())
|
||||
service := NewService(t.Context())
|
||||
|
||||
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -309,6 +321,7 @@ func TestService_purgeCache_Github(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_purgeCacheByTTL_Github(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
timeout := 100 * time.Millisecond
|
||||
@@ -316,11 +329,12 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
// 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result
|
||||
service := newService(context.TODO(), 2, 40*timeout)
|
||||
service := newService(t.Context(), 2, 40*timeout)
|
||||
|
||||
_, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
_, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
_, err = service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -340,51 +354,41 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) {
|
||||
assert.Equal(t, 0, service.repoFileCache.Len())
|
||||
}
|
||||
|
||||
func TestService_canStopCacheCleanTimer_whenContextDone(t *testing.T) {
|
||||
timeout := 10 * time.Millisecond
|
||||
deadlineCtx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(10*timeout))
|
||||
defer cancel()
|
||||
|
||||
service := NewService(deadlineCtx)
|
||||
assert.False(t, service.timerHasStopped(), "timer should not be stopped")
|
||||
|
||||
<-time.After(20 * timeout)
|
||||
|
||||
assert.True(t, service.timerHasStopped(), "timer should be stopped")
|
||||
}
|
||||
|
||||
func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), 2, 0)
|
||||
service := newService(t.Context(), 2, 0)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
|
||||
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
}
|
||||
|
||||
func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := newService(context.TODO(), 2, 0)
|
||||
service := newService(t.Context(), 2, 0)
|
||||
|
||||
repositoryUrl := privateGitRepoURL
|
||||
refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false)
|
||||
refs, err := service.ListRefs(t.Context(), repositoryUrl, username, accessToken, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(refs), 1)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
files, err := service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -399,6 +403,7 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||
|
||||
files, err = service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/test",
|
||||
username,
|
||||
@@ -412,11 +417,11 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
assert.GreaterOrEqual(t, len(files), 1)
|
||||
assert.Equal(t, 2, service.repoFileCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false)
|
||||
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", false, false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
|
||||
_, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false)
|
||||
_, err = service.ListRefs(t.Context(), repositoryUrl, username, "fake-token", true, false)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, service.repoRefCache.Len())
|
||||
// The relevant file caches should be removed too
|
||||
@@ -424,13 +429,15 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
service := newService(context.TODO(), 2, 0)
|
||||
service := newService(t.Context(), 2, 0)
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
repositoryUrl := privateGitRepoURL
|
||||
files, err := service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -445,6 +452,7 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||
assert.Equal(t, 1, service.repoFileCache.Len())
|
||||
|
||||
_, err = service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
username,
|
||||
@@ -459,9 +467,10 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_CloneRepository_TokenAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
service := newService(context.TODO(), 2, 0)
|
||||
service := newService(t.Context(), 2, 0)
|
||||
var requests []*http.Request
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, r)
|
||||
@@ -472,6 +481,7 @@ func TestService_CloneRepository_TokenAuth(t *testing.T) {
|
||||
|
||||
// Since we aren't hitting a real git server we ignore the error
|
||||
_ = service.CloneRepository(
|
||||
t.Context(),
|
||||
"test_dir",
|
||||
repositoryUrl,
|
||||
"refs/heads/main",
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/pkg/errors"
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
|
||||
func setup(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
bareRepoDir := filepath.Join(dir, "test-clone.git")
|
||||
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
|
||||
|
||||
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0o755)
|
||||
if err != nil {
|
||||
@@ -34,60 +36,103 @@ func setup(t *testing.T) string {
|
||||
return bareRepoDir
|
||||
}
|
||||
|
||||
func Test_checkGitError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "exact repository not found",
|
||||
err: errors.New("repository not found"),
|
||||
expected: gittypes.ErrIncorrectRepositoryURL,
|
||||
},
|
||||
{
|
||||
name: "repository not found with html body",
|
||||
err: errors.New("repository not found: <html><body>404 Not Found</body></html>"),
|
||||
expected: gittypes.ErrIncorrectRepositoryURL,
|
||||
},
|
||||
{
|
||||
name: "authentication required",
|
||||
err: errors.New("authentication required"),
|
||||
expected: gittypes.ErrAuthenticationFailure,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := checkGitError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("other error is unchanged", func(t *testing.T) {
|
||||
err := errors.New("some other git error")
|
||||
assert.EqualError(t, checkGitError(err), "some other git error")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
|
||||
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, getCommitHistoryLength(t, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(false)} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false)
|
||||
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
|
||||
require.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_latestCommitID(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
|
||||
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false)
|
||||
id, err := service.LatestCommitID(t.Context(), repositoryURL, referenceName, "", "", false)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
||||
}
|
||||
|
||||
func Test_ListRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(true)}
|
||||
|
||||
repositoryURL := setup(t)
|
||||
|
||||
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
|
||||
fs, err := service.ListRefs(t.Context(), repositoryURL, "", "", false, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"refs/heads/main"}, fs)
|
||||
}
|
||||
|
||||
func Test_ListFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := Service{git: NewGitClient(true)}
|
||||
|
||||
repositoryURL := setup(t)
|
||||
referenceName := "refs/heads/main"
|
||||
|
||||
fs, err := service.ListFiles(
|
||||
t.Context(),
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
"",
|
||||
@@ -124,7 +169,114 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func Test_noSymlinkFS_Symlink(t *testing.T) {
|
||||
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
|
||||
err := fs.Symlink("../../../etc/passwd", "evil-link")
|
||||
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
|
||||
}
|
||||
|
||||
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fs := NewNoSymlinkFS(osfs.New(dir))
|
||||
|
||||
f, err := fs.Create("test.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = f.Write([]byte("hello"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := fs.Stat("test.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test.txt", info.Name())
|
||||
}
|
||||
|
||||
func createBareRepoWithSymlink(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
|
||||
|
||||
repo, err := git.PlainInit(bareDir, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
storer := repo.Storer
|
||||
|
||||
fileBlob := &plumbing.MemoryObject{}
|
||||
fileBlob.SetType(plumbing.BlobObject)
|
||||
|
||||
_, err = fileBlob.Write([]byte("hello world\n"))
|
||||
require.NoError(t, err)
|
||||
|
||||
fileHash, err := storer.SetEncodedObject(fileBlob)
|
||||
require.NoError(t, err)
|
||||
|
||||
symlinkBlob := &plumbing.MemoryObject{}
|
||||
symlinkBlob.SetType(plumbing.BlobObject)
|
||||
|
||||
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
|
||||
require.NoError(t, err)
|
||||
|
||||
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
|
||||
require.NoError(t, err)
|
||||
|
||||
tree := &object.Tree{
|
||||
Entries: []object.TreeEntry{
|
||||
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
|
||||
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
|
||||
},
|
||||
}
|
||||
|
||||
treeObj := &plumbing.MemoryObject{}
|
||||
|
||||
err = tree.Encode(treeObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
treeHash, err := storer.SetEncodedObject(treeObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
|
||||
commit := &object.Commit{
|
||||
Message: "add symlink",
|
||||
Author: sig,
|
||||
Committer: sig,
|
||||
TreeHash: treeHash,
|
||||
}
|
||||
|
||||
commitObj := &plumbing.MemoryObject{}
|
||||
|
||||
err = commit.Encode(commitObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
commitHash, err := storer.SetEncodedObject(commitObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
|
||||
require.NoError(t, err)
|
||||
|
||||
return bareDir
|
||||
}
|
||||
|
||||
func Test_Download_RejectsSymlink(t *testing.T) {
|
||||
client := NewGitClient(false)
|
||||
repoURL := createBareRepoWithSymlink(t)
|
||||
|
||||
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
|
||||
URL: repoURL,
|
||||
Depth: 1,
|
||||
SingleBranch: true,
|
||||
Tags: git.NoTags,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
|
||||
}
|
||||
|
||||
func Test_listRefsPrivateRepository(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
@@ -204,7 +356,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
|
||||
Password: tt.args.password,
|
||||
}
|
||||
}
|
||||
refs, err := client.ListRefs(context.TODO(), tt.args.repositoryUrl, option)
|
||||
refs, err := client.ListRefs(t.Context(), tt.args.repositoryUrl, option)
|
||||
if tt.expect.err == nil {
|
||||
require.NoError(t, err)
|
||||
if tt.expect.refsCount > 0 {
|
||||
@@ -219,6 +371,7 @@ func Test_listRefsPrivateRepository(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_listFilesPrivateRepository(t *testing.T) {
|
||||
t.Parallel()
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
client := NewGitClient(false)
|
||||
@@ -322,7 +475,7 @@ func Test_listFilesPrivateRepository(t *testing.T) {
|
||||
Password: tt.args.password,
|
||||
}
|
||||
}
|
||||
paths, err := client.ListFiles(context.TODO(), false, option)
|
||||
paths, err := client.ListFiles(t.Context(), false, option)
|
||||
if tt.expect.shouldFail {
|
||||
require.Error(t, err)
|
||||
if tt.expect.err != nil {
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/pkg/schedule"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -29,11 +31,8 @@ type RepoManager interface {
|
||||
|
||||
// Service represents a service for managing Git.
|
||||
type Service struct {
|
||||
shutdownCtx context.Context
|
||||
azure RepoManager
|
||||
git RepoManager
|
||||
timerStopped bool
|
||||
mut sync.Mutex
|
||||
azure RepoManager
|
||||
git RepoManager
|
||||
|
||||
cacheEnabled bool
|
||||
// Cache the result of repository refs, key is repository URL
|
||||
@@ -49,75 +48,62 @@ func NewService(ctx context.Context) *Service {
|
||||
|
||||
func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Service {
|
||||
service := &Service{
|
||||
shutdownCtx: ctx,
|
||||
azure: NewAzureClient(),
|
||||
git: NewGitClient(false),
|
||||
timerStopped: false,
|
||||
cacheEnabled: cacheSize > 0,
|
||||
}
|
||||
|
||||
if service.cacheEnabled {
|
||||
var err error
|
||||
service.repoRefCache, err = lru.New(cacheSize)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failed to create ref cache")
|
||||
}
|
||||
if !service.cacheEnabled {
|
||||
return service
|
||||
}
|
||||
|
||||
service.repoFileCache, err = lru.New(cacheSize)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failed to create file cache")
|
||||
}
|
||||
var err error
|
||||
service.repoRefCache, err = lru.New(cacheSize)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failed to create ref cache")
|
||||
}
|
||||
|
||||
if cacheTTL > 0 {
|
||||
go service.startCacheCleanTimer(cacheTTL)
|
||||
}
|
||||
service.repoFileCache, err = lru.New(cacheSize)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failed to create file cache")
|
||||
}
|
||||
|
||||
if cacheTTL > 0 {
|
||||
go schedule.RunOnInterval(ctx, cacheTTL, service.purgeCache, nil)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// startCacheCleanTimer starts a timer to purge caches periodically
|
||||
func (service *Service) startCacheCleanTimer(d time.Duration) {
|
||||
ticker := time.NewTicker(d)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.purgeCache()
|
||||
|
||||
case <-service.shutdownCtx.Done():
|
||||
ticker.Stop()
|
||||
service.mut.Lock()
|
||||
service.timerStopped = true
|
||||
service.mut.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timerHasStopped shows the CacheClean timer state with thread-safe way
|
||||
func (service *Service) timerHasStopped() bool {
|
||||
service.mut.Lock()
|
||||
defer service.mut.Unlock()
|
||||
ret := service.timerStopped
|
||||
return ret
|
||||
}
|
||||
|
||||
// CloneRepository clones a git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) CloneRepository(
|
||||
ctx context.Context,
|
||||
destination,
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
password string,
|
||||
tlsSkipVerify bool,
|
||||
) error {
|
||||
return service.CloneRepositoryWithAuth(ctx, destination, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
|
||||
}
|
||||
|
||||
// CloneRepositoryWithAuth clones a git repository using the specified URL in the specified
|
||||
// destination folder, using the provided auth method.
|
||||
func (service *Service) CloneRepositoryWithAuth(
|
||||
ctx context.Context,
|
||||
destination,
|
||||
repositoryURL,
|
||||
referenceName string,
|
||||
auth transport.AuthMethod,
|
||||
tlsSkipVerify bool,
|
||||
) error {
|
||||
gitOptions := &git.CloneOptions{
|
||||
URL: repositoryURL,
|
||||
Depth: 1,
|
||||
InsecureSkipTLS: tlsSkipVerify,
|
||||
Auth: GetBasicAuth(username, password),
|
||||
Auth: auth,
|
||||
Tags: git.NoTags,
|
||||
}
|
||||
|
||||
@@ -125,7 +111,7 @@ func (service *Service) CloneRepository(
|
||||
gitOptions.ReferenceName = plumbing.ReferenceName(referenceName)
|
||||
}
|
||||
|
||||
return service.repoManager(repositoryURL).Download(context.TODO(), destination, gitOptions)
|
||||
return service.repoManager(repositoryURL).Download(ctx, destination, gitOptions)
|
||||
}
|
||||
|
||||
func (service *Service) repoManager(repositoryURL string) RepoManager {
|
||||
@@ -140,32 +126,59 @@ func (service *Service) repoManager(repositoryURL string) RepoManager {
|
||||
|
||||
// LatestCommitID returns SHA1 of the latest commit of the specified reference
|
||||
func (service *Service) LatestCommitID(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
password string,
|
||||
tlsSkipVerify bool,
|
||||
) (string, error) {
|
||||
return service.LatestCommitIDWithAuth(ctx, repositoryURL, referenceName, GetBasicAuth(username, password), tlsSkipVerify)
|
||||
}
|
||||
|
||||
// LatestCommitIDWithAuth returns SHA1 of the latest commit of the specified reference,
|
||||
// using the provided auth method.
|
||||
func (service *Service) LatestCommitIDWithAuth(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
referenceName string,
|
||||
auth transport.AuthMethod,
|
||||
tlsSkipVerify bool,
|
||||
) (string, error) {
|
||||
listOptions := &git.ListOptions{
|
||||
Auth: GetBasicAuth(username, password),
|
||||
Auth: auth,
|
||||
InsecureSkipTLS: tlsSkipVerify,
|
||||
}
|
||||
|
||||
return service.repoManager(repositoryURL).LatestCommitID(context.TODO(), repositoryURL, referenceName, listOptions)
|
||||
return service.repoManager(repositoryURL).LatestCommitID(ctx, repositoryURL, referenceName, listOptions)
|
||||
}
|
||||
|
||||
// ListRefs will list target repository's references without cloning the repository
|
||||
func (service *Service) ListRefs(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
username,
|
||||
password string,
|
||||
hardRefresh bool,
|
||||
tlsSkipVerify bool,
|
||||
) ([]string, error) {
|
||||
refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
|
||||
cacheKey := GenerateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify))
|
||||
return service.ListRefsWithAuth(ctx, repositoryURL, hardRefresh, GetBasicAuth(username, password), tlsSkipVerify, cacheKey)
|
||||
}
|
||||
|
||||
// ListRefsWithAuth will list target repository's references without cloning the repository,
|
||||
// using the provided auth method. The cacheKey is supplied by the caller.
|
||||
func (service *Service) ListRefsWithAuth(
|
||||
ctx context.Context,
|
||||
repositoryURL string,
|
||||
hardRefresh bool,
|
||||
auth transport.AuthMethod,
|
||||
tlsSkipVerify bool,
|
||||
cacheKey string,
|
||||
) ([]string, error) {
|
||||
if service.cacheEnabled && hardRefresh {
|
||||
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
||||
service.repoRefCache.Remove(refCacheKey)
|
||||
service.repoRefCache.Remove(cacheKey)
|
||||
// Remove file caches pointed to the same repository
|
||||
for _, fileCacheKey := range service.repoFileCache.Keys() {
|
||||
if key, ok := fileCacheKey.(string); ok && strings.HasPrefix(key, repositoryURL) {
|
||||
@@ -176,7 +189,7 @@ func (service *Service) ListRefs(
|
||||
|
||||
if service.repoRefCache != nil {
|
||||
// Lookup the refs cache first
|
||||
if cache, ok := service.repoRefCache.Get(refCacheKey); ok {
|
||||
if cache, ok := service.repoRefCache.Get(cacheKey); ok {
|
||||
if refs, ok := cache.([]string); ok {
|
||||
return refs, nil
|
||||
}
|
||||
@@ -184,17 +197,17 @@ func (service *Service) ListRefs(
|
||||
}
|
||||
|
||||
options := &git.ListOptions{
|
||||
Auth: GetBasicAuth(username, password),
|
||||
Auth: auth,
|
||||
InsecureSkipTLS: tlsSkipVerify,
|
||||
}
|
||||
|
||||
refs, err := service.repoManager(repositoryURL).ListRefs(context.TODO(), repositoryURL, options)
|
||||
refs, err := service.repoManager(repositoryURL).ListRefs(ctx, repositoryURL, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if service.cacheEnabled && service.repoRefCache != nil {
|
||||
service.repoRefCache.Add(refCacheKey, refs)
|
||||
service.repoRefCache.Add(cacheKey, refs)
|
||||
}
|
||||
|
||||
return refs, nil
|
||||
@@ -205,6 +218,7 @@ var singleflightGroup = &singleflight.Group{}
|
||||
// ListFiles will list all the files of the target repository with specific extensions.
|
||||
// If extension is not provided, it will list all the files under the target repository
|
||||
func (service *Service) ListFiles(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
@@ -214,7 +228,7 @@ func (service *Service) ListFiles(
|
||||
includedExts []string,
|
||||
tlsSkipVerify bool,
|
||||
) ([]string, error) {
|
||||
repoKey := generateCacheKey(
|
||||
cacheKey := GenerateCacheKey(
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
@@ -222,48 +236,47 @@ func (service *Service) ListFiles(
|
||||
strconv.FormatBool(tlsSkipVerify),
|
||||
strconv.FormatBool(dirOnly),
|
||||
)
|
||||
return service.ListFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, GetBasicAuth(username, password), includedExts, tlsSkipVerify, cacheKey)
|
||||
}
|
||||
|
||||
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
|
||||
return service.listFiles(
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
password,
|
||||
dirOnly,
|
||||
hardRefresh,
|
||||
tlsSkipVerify,
|
||||
)
|
||||
// ListFilesWithAuth will list all the files of the target repository with specific extensions,
|
||||
// using the provided auth method. The cacheKey is supplied by the caller.
|
||||
func (service *Service) ListFilesWithAuth(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
referenceName string,
|
||||
dirOnly,
|
||||
hardRefresh bool,
|
||||
auth transport.AuthMethod,
|
||||
includedExts []string,
|
||||
tlsSkipVerify bool,
|
||||
cacheKey string,
|
||||
) ([]string, error) {
|
||||
fs, err, _ := singleflightGroup.Do(cacheKey, func() (any, error) {
|
||||
return service.listFilesWithAuth(ctx, repositoryURL, referenceName, dirOnly, hardRefresh, auth, tlsSkipVerify, cacheKey)
|
||||
})
|
||||
|
||||
return filterFiles(fs.([]string), includedExts), err
|
||||
}
|
||||
|
||||
func (service *Service) listFiles(
|
||||
func (service *Service) listFilesWithAuth(
|
||||
ctx context.Context,
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
password string,
|
||||
referenceName string,
|
||||
dirOnly,
|
||||
hardRefresh bool,
|
||||
auth transport.AuthMethod,
|
||||
tlsSkipVerify bool,
|
||||
cacheKey string,
|
||||
) ([]string, error) {
|
||||
repoKey := generateCacheKey(
|
||||
repositoryURL,
|
||||
referenceName,
|
||||
username,
|
||||
password,
|
||||
strconv.FormatBool(tlsSkipVerify),
|
||||
strconv.FormatBool(dirOnly),
|
||||
)
|
||||
|
||||
if service.cacheEnabled && hardRefresh {
|
||||
// Should remove the cache explicitly, so that the following normal list can show the correct result
|
||||
service.repoFileCache.Remove(repoKey)
|
||||
service.repoFileCache.Remove(cacheKey)
|
||||
}
|
||||
|
||||
if service.repoFileCache != nil {
|
||||
// lookup the files cache first
|
||||
if cache, ok := service.repoFileCache.Get(repoKey); ok {
|
||||
if cache, ok := service.repoFileCache.Get(cacheKey); ok {
|
||||
if files, ok := cache.([]string); ok {
|
||||
return files, nil
|
||||
}
|
||||
@@ -276,18 +289,18 @@ func (service *Service) listFiles(
|
||||
Depth: 1,
|
||||
SingleBranch: true,
|
||||
ReferenceName: plumbing.ReferenceName(referenceName),
|
||||
Auth: GetBasicAuth(username, password),
|
||||
Auth: auth,
|
||||
InsecureSkipTLS: tlsSkipVerify,
|
||||
Tags: git.NoTags,
|
||||
}
|
||||
|
||||
files, err := service.repoManager(repositoryURL).ListFiles(context.TODO(), dirOnly, cloneOption)
|
||||
files, err := service.repoManager(repositoryURL).ListFiles(ctx, dirOnly, cloneOption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if service.cacheEnabled && service.repoFileCache != nil {
|
||||
service.repoFileCache.Add(repoKey, files)
|
||||
service.repoFileCache.Add(cacheKey, files)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
@@ -303,7 +316,8 @@ func (service *Service) purgeCache() {
|
||||
}
|
||||
}
|
||||
|
||||
func generateCacheKey(names ...string) string {
|
||||
// GenerateCacheKey generates a cache key from the given parts.
|
||||
func GenerateCacheKey(names ...string) string {
|
||||
return strings.Join(names, "-")
|
||||
}
|
||||
|
||||
@@ -338,15 +352,16 @@ func filterFiles(paths []string, includedExts []string) []string {
|
||||
}
|
||||
|
||||
func GetBasicAuth(username, password string) *githttp.BasicAuth {
|
||||
if password != "" {
|
||||
if username == "" {
|
||||
username = "token"
|
||||
}
|
||||
|
||||
return &githttp.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
if password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
username = "token"
|
||||
}
|
||||
|
||||
return &githttp.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
343
api/git/service_test.go
Normal file
343
api/git/service_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
type mockRepoManager struct {
|
||||
downloadErr error
|
||||
commitID string
|
||||
commitIDErr error
|
||||
refs []string
|
||||
refsErr error
|
||||
files []string
|
||||
filesErr error
|
||||
|
||||
downloadCalled int
|
||||
commitIDCalled int
|
||||
listRefsCalled int
|
||||
listFilesCalled int
|
||||
lastCloneOptions *git.CloneOptions
|
||||
}
|
||||
|
||||
func (m *mockRepoManager) Download(_ context.Context, _ string, opts *git.CloneOptions) error {
|
||||
m.downloadCalled++
|
||||
m.lastCloneOptions = opts
|
||||
return m.downloadErr
|
||||
}
|
||||
|
||||
func (m *mockRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) {
|
||||
m.commitIDCalled++
|
||||
return m.commitID, m.commitIDErr
|
||||
}
|
||||
|
||||
func (m *mockRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) {
|
||||
m.listRefsCalled++
|
||||
return m.refs, m.refsErr
|
||||
}
|
||||
|
||||
func (m *mockRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) {
|
||||
m.listFilesCalled++
|
||||
return m.files, m.filesErr
|
||||
}
|
||||
|
||||
func newTestService(ctx context.Context, cacheSize int, gitMgr, azureMgr RepoManager) *Service {
|
||||
s := newService(ctx, cacheSize, 0)
|
||||
s.git = gitMgr
|
||||
s.azure = azureMgr
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCloneRepository(t *testing.T) {
|
||||
t.Parallel()
|
||||
downloadErr := errors.New("clone failed")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
referenceName string
|
||||
gitManagerDownloadCalled int
|
||||
azureManagerDownloadCalled int
|
||||
managerErr bool
|
||||
expectedError error
|
||||
expectedReferenceName string
|
||||
}{
|
||||
{
|
||||
name: "non-azure URL routes to git manager",
|
||||
url: "https://github.com/example/repo.git",
|
||||
gitManagerDownloadCalled: 1,
|
||||
azureManagerDownloadCalled: 0,
|
||||
},
|
||||
{
|
||||
name: "azure URL routes to azure manager",
|
||||
url: "https://dev.azure.com/org/project/_git/repo",
|
||||
gitManagerDownloadCalled: 0,
|
||||
azureManagerDownloadCalled: 1,
|
||||
},
|
||||
{
|
||||
name: "error from manager propagated",
|
||||
url: "https://github.com/example/repo.git",
|
||||
managerErr: true,
|
||||
gitManagerDownloadCalled: 1,
|
||||
expectedError: downloadErr,
|
||||
},
|
||||
{
|
||||
name: "ReferenceName is passed to clone options",
|
||||
url: "https://github.com/example/repo.git",
|
||||
referenceName: "refs/heads/feature-branch",
|
||||
gitManagerDownloadCalled: 1,
|
||||
expectedReferenceName: "refs/heads/feature-branch",
|
||||
},
|
||||
{
|
||||
name: "empty ReferenceName leaves clone options unset",
|
||||
url: "https://github.com/example/repo.git",
|
||||
referenceName: "",
|
||||
gitManagerDownloadCalled: 1,
|
||||
expectedReferenceName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{}
|
||||
azureMgr := &mockRepoManager{}
|
||||
if tc.managerErr {
|
||||
gitMgr.downloadErr = downloadErr
|
||||
azureMgr.downloadErr = downloadErr
|
||||
}
|
||||
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
|
||||
|
||||
err := s.CloneRepository(t.Context(), "/tmp", tc.url, tc.referenceName, "", "", false)
|
||||
require.Equal(t, tc.expectedError, err)
|
||||
require.Equal(t, tc.gitManagerDownloadCalled, gitMgr.downloadCalled)
|
||||
require.Equal(t, tc.azureManagerDownloadCalled, azureMgr.downloadCalled)
|
||||
|
||||
activeMgr := gitMgr
|
||||
if tc.azureManagerDownloadCalled > 0 {
|
||||
activeMgr = azureMgr
|
||||
}
|
||||
if activeMgr.lastCloneOptions != nil {
|
||||
require.Equal(t, plumbing.ReferenceName(tc.expectedReferenceName), activeMgr.lastCloneOptions.ReferenceName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCommitID(t *testing.T) {
|
||||
t.Parallel()
|
||||
commitLookupErr := errors.New("commit lookup failed")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
gitCommitID string
|
||||
azureCommitID string
|
||||
commitIDErr error
|
||||
expectedID string
|
||||
expectedError error
|
||||
gitCalled int
|
||||
azureCalled int
|
||||
}{
|
||||
{
|
||||
name: "non-azure URL routes to git manager",
|
||||
url: "https://github.com/example/repo.git",
|
||||
gitCommitID: "abc123",
|
||||
expectedID: "abc123",
|
||||
gitCalled: 1,
|
||||
},
|
||||
{
|
||||
name: "azure URL routes to azure manager",
|
||||
url: "https://dev.azure.com/org/project/_git/repo",
|
||||
azureCommitID: "def456",
|
||||
expectedID: "def456",
|
||||
azureCalled: 1,
|
||||
},
|
||||
{
|
||||
name: "error propagated",
|
||||
url: "https://github.com/example/repo.git",
|
||||
commitIDErr: commitLookupErr,
|
||||
expectedError: commitLookupErr,
|
||||
gitCalled: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{commitID: tc.gitCommitID, commitIDErr: tc.commitIDErr}
|
||||
azureMgr := &mockRepoManager{commitID: tc.azureCommitID}
|
||||
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
|
||||
|
||||
id, err := s.LatestCommitID(t.Context(), tc.url, "", "", "", false)
|
||||
require.Equal(t, tc.expectedError, err)
|
||||
require.Equal(t, tc.expectedID, id)
|
||||
require.Equal(t, tc.gitCalled, gitMgr.commitIDCalled)
|
||||
require.Equal(t, tc.azureCalled, azureMgr.commitIDCalled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("cache hit on second call", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main", "refs/heads/develop"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
refs1, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
refs2, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, gitMgr.listRefsCalled, "expected manager to be called once")
|
||||
require.Equal(t, refs1, refs2)
|
||||
})
|
||||
|
||||
t.Run("hard refresh clears cache and calls manager again", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with hard refresh")
|
||||
})
|
||||
|
||||
t.Run("error propagated and not cached", func(t *testing.T) {
|
||||
wantErr := errors.New("refs failed")
|
||||
gitMgr := &mockRepoManager{refsErr: wantErr}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.Equal(t, wantErr, err)
|
||||
|
||||
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false)
|
||||
require.Equal(t, wantErr, err)
|
||||
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice after error")
|
||||
})
|
||||
|
||||
t.Run("azure URL routes to azure manager", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{}
|
||||
azureMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
|
||||
|
||||
_, err := s.ListRefs(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, azureMgr.listRefsCalled, "expected azure.ListRefs to be called once")
|
||||
require.Equal(t, 0, gitMgr.listRefsCalled, "expected git.ListRefs to not be called")
|
||||
})
|
||||
|
||||
t.Run("cache disabled: manager always called", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}}
|
||||
s := newTestService(t.Context(), 0, gitMgr, &mockRepoManager{})
|
||||
|
||||
_, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
_, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with cache disabled")
|
||||
})
|
||||
}
|
||||
|
||||
func TestListFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("cache hit on second call", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
files1, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
files2, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, gitMgr.listFilesCalled, "expected manager to be called once")
|
||||
require.Equal(t, files1, files2)
|
||||
})
|
||||
|
||||
t.Run("hard refresh clears file cache", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
_, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
|
||||
require.NoError(t, err)
|
||||
_, err = s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, true, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 2, gitMgr.listFilesCalled, "expected manager to be called twice with hard refresh")
|
||||
})
|
||||
|
||||
t.Run("azure URL routes to azure manager", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{}
|
||||
azureMgr := &mockRepoManager{files: []string{"docker-compose.yml"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, azureMgr)
|
||||
|
||||
_, err := s.ListFiles(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", "", false, false, nil, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, azureMgr.listFilesCalled, "expected azure.ListFiles to be called once")
|
||||
require.Equal(t, 0, gitMgr.listFilesCalled, "expected git.ListFiles to not be called")
|
||||
})
|
||||
|
||||
t.Run("extension filter applied", func(t *testing.T) {
|
||||
gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md", "stack.yml", "config.json"}}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "", "", "", false, false, []string{".yml"}, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"docker-compose.yml", "stack.yml"}, files)
|
||||
})
|
||||
|
||||
t.Run("error is returned and not cached", func(t *testing.T) {
|
||||
wantErr := errors.New("list files failed")
|
||||
gitMgr := &mockRepoManager{filesErr: wantErr}
|
||||
s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{})
|
||||
|
||||
files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false)
|
||||
require.ErrorIs(t, err, wantErr)
|
||||
require.Nil(t, files)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
files []string
|
||||
exts []string
|
||||
expectedFiles []string
|
||||
}{
|
||||
{
|
||||
name: "empty ext list returns all files",
|
||||
files: []string{"a.yml", "b.json", "c.txt"},
|
||||
exts: nil,
|
||||
expectedFiles: []string{"a.yml", "b.json", "c.txt"},
|
||||
},
|
||||
{
|
||||
name: "non-matching exts returns empty",
|
||||
files: []string{"a.yml", "b.json"},
|
||||
exts: []string{".txt"},
|
||||
expectedFiles: nil,
|
||||
},
|
||||
{
|
||||
name: "partial match returns only matching files",
|
||||
files: []string{"a.yml", "b.json", "c.yml"},
|
||||
exts: []string{".yml"},
|
||||
expectedFiles: []string{"a.yml", "c.yml"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.expectedFiles, filterFiles(tc.files, tc.exts))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
var (
|
||||
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
|
||||
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
|
||||
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
|
||||
)
|
||||
|
||||
// RepoConfig represents a configuration for a repo
|
||||
@@ -15,7 +16,8 @@ type RepoConfig struct {
|
||||
URL string `example:"https://github.com/portainer/portainer.git"`
|
||||
// The reference name
|
||||
ReferenceName string `example:"refs/heads/branch_name"`
|
||||
// Path to where the config file is in this url/refName
|
||||
// ConfigFilePath is the path to the config file within the repository.
|
||||
// NOTE: For stacks, this mirrors Stack.EntryPoint and the two are kept in sync by stackUpdateGit.
|
||||
ConfigFilePath string `example:"docker-compose.yml"`
|
||||
// Git credentials
|
||||
Authentication *GitAuthentication
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// UpdateGitObject updates a git object based on its config
|
||||
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
func UpdateGitObject(ctx context.Context, gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
if gitConfig == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -24,12 +25,10 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
Str("object", objId).
|
||||
Msg("the object has a git config, try to poll from git repository")
|
||||
|
||||
username, password, err := git.GetCredentials(gitConfig.Authentication)
|
||||
if err != nil {
|
||||
return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId)
|
||||
}
|
||||
username, password := git.GetCredentials(gitConfig.Authentication)
|
||||
|
||||
newHash, err := gitService.LatestCommitID(
|
||||
ctx,
|
||||
gitConfig.URL,
|
||||
gitConfig.ReferenceName,
|
||||
username,
|
||||
@@ -71,7 +70,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloneGitRepository(gitService, cloneParams); err != nil {
|
||||
if err := cloneGitRepository(ctx, gitService, cloneParams); err != nil {
|
||||
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
|
||||
}
|
||||
|
||||
@@ -99,24 +98,20 @@ type gitAuth struct {
|
||||
password string
|
||||
}
|
||||
|
||||
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
|
||||
func cloneGitRepository(ctx context.Context, gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
|
||||
username, password := "", ""
|
||||
if cloneParams.auth != nil {
|
||||
return gitService.CloneRepository(
|
||||
cloneParams.toDir,
|
||||
cloneParams.url,
|
||||
cloneParams.ref,
|
||||
cloneParams.auth.username,
|
||||
cloneParams.auth.password,
|
||||
cloneParams.tlsSkipVerify,
|
||||
)
|
||||
username = cloneParams.auth.username
|
||||
password = cloneParams.auth.password
|
||||
}
|
||||
|
||||
return gitService.CloneRepository(
|
||||
ctx,
|
||||
cloneParams.toDir,
|
||||
cloneParams.url,
|
||||
cloneParams.ref,
|
||||
"",
|
||||
"",
|
||||
username,
|
||||
password,
|
||||
cloneParams.tlsSkipVerify,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func Test_ValidateAutoUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
value *portainer.AutoUpdateSettings
|
||||
|
||||
59
api/gitops/workflows/git_phases.go
Normal file
59
api/gitops/workflows/git_phases.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ListRefsFunc lists all git refs for a repository.
|
||||
type ListRefsFunc func(ctx context.Context) ([]string, error)
|
||||
|
||||
// ListFilesFunc lists files in a repository branch filtered by extension.
|
||||
type ListFilesFunc func(ctx context.Context, exts []string) ([]string, error)
|
||||
|
||||
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
|
||||
// If source fails, artifact is returned as unknown without making a network call.
|
||||
func ComputeGitPhases(ctx context.Context, referenceName, configFilePath string, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
|
||||
source = computeSourcePhase(ctx, referenceName, listRefs)
|
||||
if source.Status == StatusError {
|
||||
return source, WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
return source, computeArtifactPhase(ctx, configFilePath, listFiles)
|
||||
}
|
||||
|
||||
func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus {
|
||||
refs, err := listRefs(ctx)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
if referenceName == "" {
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
if !slices.Contains(refs, referenceName) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
|
||||
func computeArtifactPhase(ctx context.Context, configFilePath string, listFiles ListFilesFunc) WorkflowPhaseStatus {
|
||||
if configFilePath == "" {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
|
||||
}
|
||||
ext := path.Ext(configFilePath)
|
||||
var exts []string
|
||||
if len(ext) > 0 {
|
||||
ext = ext[1:]
|
||||
exts = []string{ext}
|
||||
}
|
||||
|
||||
files, err := listFiles(ctx, exts)
|
||||
if err != nil {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
|
||||
}
|
||||
if !slices.Contains(files, configFilePath) {
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", configFilePath)}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
162
api/gitops/workflows/git_phases_test.go
Normal file
162
api/gitops/workflows/git_phases_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeGitPhases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
okRefs := func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
}
|
||||
okFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
return []string{"docker-compose.yml"}, nil
|
||||
}
|
||||
errRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
errFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
referenceName string
|
||||
configFilePath string
|
||||
listRefs ListRefsFunc
|
||||
listFiles ListFilesFunc
|
||||
expectedSource Status
|
||||
expectedArtifact Status
|
||||
}{
|
||||
{
|
||||
name: "listRefs errors → source error, artifact unknown",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: errRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "ref not in list → source error, artifact unknown",
|
||||
referenceName: "refs/heads/missing",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: func(_ context.Context) ([]string, error) {
|
||||
return []string{"refs/heads/main"}, nil
|
||||
},
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusError,
|
||||
expectedArtifact: StatusUnknown,
|
||||
},
|
||||
{
|
||||
name: "empty configFilePath → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "listFiles errors → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: errFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "file not in list → artifact error",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: func(_ context.Context, _ []string) ([]string, error) {
|
||||
return []string{"other.yml"}, nil
|
||||
},
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusError,
|
||||
},
|
||||
{
|
||||
name: "both healthy",
|
||||
referenceName: "refs/heads/main",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "empty referenceName → source healthy (default HEAD)",
|
||||
referenceName: "",
|
||||
configFilePath: "docker-compose.yml",
|
||||
listRefs: okRefs,
|
||||
listFiles: okFiles,
|
||||
expectedSource: StatusHealthy,
|
||||
expectedArtifact: StatusHealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source, artifact := ComputeGitPhases(t.Context(), tc.referenceName, tc.configFilePath, tc.listRefs, tc.listFiles)
|
||||
assert.Equal(t, tc.expectedSource, source.Status)
|
||||
assert.Equal(t, tc.expectedArtifact, artifact.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
wantExts []string
|
||||
}{
|
||||
{"docker-compose.yml", []string{"yml"}},
|
||||
{"stack.yaml", []string{"yaml"}},
|
||||
{"subdir/compose.yml", []string{"yml"}},
|
||||
{"Makefile", nil},
|
||||
{"archive.tar.gz", []string{"gz"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.configPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedExts []string
|
||||
ComputeGitPhases(
|
||||
t.Context(),
|
||||
"",
|
||||
tc.configPath,
|
||||
func(_ context.Context) ([]string, error) { return nil, nil },
|
||||
func(_ context.Context, exts []string) ([]string, error) {
|
||||
capturedExts = exts
|
||||
return []string{tc.configPath}, nil
|
||||
},
|
||||
)
|
||||
assert.Equal(t, tc.wantExts, capturedExts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listFilesCalled := false
|
||||
listRefs := func(_ context.Context) ([]string, error) {
|
||||
return nil, errors.New("repo unreachable")
|
||||
}
|
||||
listFiles := func(_ context.Context, _ []string) ([]string, error) {
|
||||
listFilesCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ComputeGitPhases(t.Context(), "refs/heads/main", "docker-compose.yml", listRefs, listFiles)
|
||||
|
||||
assert.False(t, listFilesCalled, "listFiles must not be called when source fails")
|
||||
}
|
||||
137
api/gitops/workflows/mapping.go
Normal file
137
api/gitops/workflows/mapping.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
return Workflow{
|
||||
ID: int(s.ID),
|
||||
Name: s.Name,
|
||||
Type: TypeStack,
|
||||
Platform: platformFromStackType(s.Type),
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveStackTargetState(s),
|
||||
},
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EndpointID: s.EndpointID,
|
||||
Namespace: s.Namespace,
|
||||
},
|
||||
CreationDate: s.CreationDate,
|
||||
LastSyncDate: stackLastSyncDate(s),
|
||||
}
|
||||
}
|
||||
|
||||
// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
platform := DeploymentPlatformDockerStandalone
|
||||
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
platform = DeploymentPlatformKubernetes
|
||||
}
|
||||
return Workflow{
|
||||
ID: int(es.ID),
|
||||
Name: es.Name,
|
||||
Type: TypeEdgeStack,
|
||||
Platform: platform,
|
||||
Status: WorkflowStatusObject{
|
||||
Source: source,
|
||||
Artifact: artifact,
|
||||
Target: deriveEdgeStackTargetState(statuses),
|
||||
},
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EdgeGroupIDs: es.EdgeGroups,
|
||||
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
|
||||
},
|
||||
CreationDate: es.CreationDate,
|
||||
LastSyncDate: edgeStackLastSyncDate(statuses),
|
||||
}
|
||||
}
|
||||
|
||||
func stackLastSyncDate(s portainer.Stack) int64 {
|
||||
for i := len(s.DeploymentStatus) - 1; i >= 0; i-- {
|
||||
if s.DeploymentStatus[i].Status == portainer.StackStatusActive {
|
||||
return s.DeploymentStatus[i].Time
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func edgeStackLastSyncDate(statuses []portainer.EdgeStackStatusForEnv) int64 {
|
||||
var oldest int64
|
||||
for _, epStatus := range statuses {
|
||||
last := endpointLastSyncDate(epStatus)
|
||||
if last == 0 {
|
||||
return 0
|
||||
}
|
||||
if oldest == 0 || last < oldest {
|
||||
oldest = last
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
func endpointLastSyncDate(epStatus portainer.EdgeStackStatusForEnv) int64 {
|
||||
for i := len(epStatus.Status) - 1; i >= 0; i-- {
|
||||
if isEdgeStackHealthyStatus(epStatus.Status[i].Type) {
|
||||
return epStatus.Status[i].Time
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func platformFromStackType(t portainer.StackType) DeploymentPlatform {
|
||||
switch t {
|
||||
case portainer.KubernetesStack:
|
||||
return DeploymentPlatformKubernetes
|
||||
case portainer.DockerSwarmStack:
|
||||
return DeploymentPlatformDockerSwarm
|
||||
default:
|
||||
return DeploymentPlatformDockerStandalone
|
||||
}
|
||||
}
|
||||
|
||||
func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool {
|
||||
switch t {
|
||||
case portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusRolledBack,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
portainer.EdgeStackStatusRemoved,
|
||||
portainer.EdgeStackStatusRemoteUpdateSuccess:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func edgeStackTargetStatuses(
|
||||
groups []portainer.EdgeGroupID,
|
||||
statuses []portainer.EdgeStackStatusForEnv,
|
||||
groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID,
|
||||
) map[portainer.EdgeGroupID]Status {
|
||||
epMap := make(map[portainer.EndpointID]Status, len(statuses))
|
||||
for _, s := range statuses {
|
||||
ws, _ := endpointWorkflowStatus(s)
|
||||
epMap[s.EndpointID] = ws
|
||||
}
|
||||
|
||||
result := make(map[portainer.EdgeGroupID]Status, len(groups))
|
||||
for _, gid := range groups {
|
||||
gStatus := StatusUnknown
|
||||
for _, epID := range groupEndpoints[gid] {
|
||||
if ws := epMap[epID]; statusPriority(ws) > statusPriority(gStatus) {
|
||||
gStatus = ws
|
||||
}
|
||||
}
|
||||
result[gid] = gStatus
|
||||
}
|
||||
return result
|
||||
}
|
||||
149
api/gitops/workflows/mapping_test.go
Normal file
149
api/gitops/workflows/mapping_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no deployment status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), stackLastSyncDate(portainer.Stack{}))
|
||||
})
|
||||
|
||||
t.Run("no active entry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(0), stackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("last entry is active", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: 50},
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
}}
|
||||
assert.Equal(t, int64(100), stackLastSyncDate(s))
|
||||
})
|
||||
|
||||
t.Run("active followed by non-active returns the active time", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusActive, Time: 100},
|
||||
{Status: portainer.StackStatusDeploying, Time: 200},
|
||||
}}
|
||||
assert.Equal(t, int64(100), stackLastSyncDate(s))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackLastSyncDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(nil))
|
||||
})
|
||||
|
||||
t.Run("no healthy status for endpoint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("single endpoint with healthy status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(200), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("returns minimum healthy time across endpoints", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 300},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(100), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
|
||||
t.Run("one endpoint not yet synced returns 0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
statuses := []portainer.EdgeStackStatusForEnv{
|
||||
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusRunning, Time: 200},
|
||||
}},
|
||||
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
|
||||
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
|
||||
}},
|
||||
}
|
||||
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeStackTargetStatuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("group with no endpoints is unknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
nil,
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {}},
|
||||
)
|
||||
assert.Equal(t, StatusUnknown, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("group inherits highest-priority endpoint status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{1},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {1, 2}},
|
||||
)
|
||||
assert.Equal(t, StatusSyncing, result[portainer.EdgeGroupID(1)])
|
||||
})
|
||||
|
||||
t.Run("multiple groups tracked separately", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := edgeStackTargetStatuses(
|
||||
[]portainer.EdgeGroupID{10, 20},
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusRunning),
|
||||
ep(2, portainer.EdgeStackStatusError),
|
||||
},
|
||||
map[portainer.EdgeGroupID][]portainer.EndpointID{
|
||||
10: {1},
|
||||
20: {2},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, StatusHealthy, result[portainer.EdgeGroupID(10)])
|
||||
assert.Equal(t, StatusError, result[portainer.EdgeGroupID(20)])
|
||||
})
|
||||
}
|
||||
112
api/gitops/workflows/status.go
Normal file
112
api/gitops/workflows/status.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package workflows
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
func deriveStackTargetState(s portainer.Stack) WorkflowPhaseStatus {
|
||||
if len(s.DeploymentStatus) == 0 {
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
}
|
||||
last := s.DeploymentStatus[len(s.DeploymentStatus)-1]
|
||||
switch last.Status {
|
||||
case portainer.StackStatusActive:
|
||||
return WorkflowPhaseStatus{Status: StatusHealthy}
|
||||
case portainer.StackStatusError:
|
||||
return WorkflowPhaseStatus{Status: StatusError, Error: last.Message}
|
||||
case portainer.StackStatusDeploying:
|
||||
return WorkflowPhaseStatus{Status: StatusSyncing}
|
||||
case portainer.StackStatusInactive:
|
||||
return WorkflowPhaseStatus{Status: StatusPaused}
|
||||
default:
|
||||
return WorkflowPhaseStatus{Status: StatusUnknown}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveEdgeStackTargetState(statuses []portainer.EdgeStackStatusForEnv) WorkflowPhaseStatus {
|
||||
result := StatusUnknown
|
||||
for _, epStatus := range statuses {
|
||||
ws, msg := endpointWorkflowStatus(epStatus)
|
||||
if ws == StatusError {
|
||||
return WorkflowPhaseStatus{Status: ws, Error: msg}
|
||||
}
|
||||
if statusPriority(ws) > statusPriority(result) {
|
||||
result = ws
|
||||
}
|
||||
}
|
||||
return WorkflowPhaseStatus{Status: result}
|
||||
}
|
||||
|
||||
func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) {
|
||||
if len(epStatus.Status) == 0 {
|
||||
return StatusUnknown, ""
|
||||
}
|
||||
last := epStatus.Status[len(epStatus.Status)-1]
|
||||
switch last.Type {
|
||||
case portainer.EdgeStackStatusError:
|
||||
return StatusError, last.Error
|
||||
case portainer.EdgeStackStatusDeploying,
|
||||
portainer.EdgeStackStatusRollingBack,
|
||||
portainer.EdgeStackStatusRemoving,
|
||||
portainer.EdgeStackStatusPending,
|
||||
portainer.EdgeStackStatusDeploymentReceived,
|
||||
portainer.EdgeStackStatusAcknowledged,
|
||||
portainer.EdgeStackStatusImagesPulled:
|
||||
return StatusSyncing, ""
|
||||
case portainer.EdgeStackStatusPausedDeploying:
|
||||
return StatusPaused, ""
|
||||
case portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusRolledBack,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
portainer.EdgeStackStatusRemoved,
|
||||
portainer.EdgeStackStatusRemoteUpdateSuccess:
|
||||
return StatusHealthy, ""
|
||||
default:
|
||||
return StatusUnknown, ""
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveStatus returns the highest-priority status across all three phases of a workflow.
|
||||
func EffectiveStatus(w Workflow) Status {
|
||||
s := w.Status.Target.Status
|
||||
if statusPriority(w.Status.Source.Status) > statusPriority(s) {
|
||||
s = w.Status.Source.Status
|
||||
}
|
||||
if statusPriority(w.Status.Artifact.Status) > statusPriority(s) {
|
||||
s = w.Status.Artifact.Status
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CountByStatus counts workflows per effective status and returns a StatusSummary.
|
||||
func CountByStatus(workflows []Workflow) StatusSummary {
|
||||
var s StatusSummary
|
||||
for _, w := range workflows {
|
||||
switch EffectiveStatus(w) {
|
||||
case StatusHealthy:
|
||||
s.Healthy++
|
||||
case StatusSyncing:
|
||||
s.Syncing++
|
||||
case StatusError:
|
||||
s.Error++
|
||||
case StatusPaused:
|
||||
s.Paused++
|
||||
default:
|
||||
s.Unknown++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func statusPriority(s Status) int {
|
||||
switch s {
|
||||
case StatusError:
|
||||
return 4
|
||||
case StatusSyncing:
|
||||
return 3
|
||||
case StatusPaused:
|
||||
return 2
|
||||
case StatusHealthy:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
151
api/gitops/workflows/status_test.go
Normal file
151
api/gitops/workflows/status_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEffectiveStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeWorkflow := func(source, artifact, target Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: source},
|
||||
Artifact: WorkflowPhaseStatus{Status: artifact},
|
||||
Target: WorkflowPhaseStatus{Status: target},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
w Workflow
|
||||
want Status
|
||||
}{
|
||||
{"all healthy", makeWorkflow(StatusHealthy, StatusHealthy, StatusHealthy), StatusHealthy},
|
||||
{"all unknown", makeWorkflow(StatusUnknown, StatusUnknown, StatusUnknown), StatusUnknown},
|
||||
{"source error wins over syncing target", makeWorkflow(StatusError, StatusSyncing, StatusHealthy), StatusError},
|
||||
{"artifact error wins over syncing target", makeWorkflow(StatusHealthy, StatusError, StatusSyncing), StatusError},
|
||||
{"target error wins over healthy phases", makeWorkflow(StatusHealthy, StatusHealthy, StatusError), StatusError},
|
||||
{"syncing beats paused and healthy", makeWorkflow(StatusPaused, StatusSyncing, StatusHealthy), StatusSyncing},
|
||||
{"paused beats healthy", makeWorkflow(StatusHealthy, StatusPaused, StatusHealthy), StatusPaused},
|
||||
{"healthy beats unknown", makeWorkflow(StatusUnknown, StatusHealthy, StatusUnknown), StatusHealthy},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, EffectiveStatus(tc.w))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeW := func(s Status) Workflow {
|
||||
return Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: s},
|
||||
Artifact: WorkflowPhaseStatus{Status: s},
|
||||
Target: WorkflowPhaseStatus{Status: s},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{}, CountByStatus(nil))
|
||||
})
|
||||
|
||||
t.Run("single healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)}))
|
||||
})
|
||||
|
||||
t.Run("mixed statuses", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflows := []Workflow{
|
||||
makeW(StatusHealthy),
|
||||
makeW(StatusError),
|
||||
makeW(StatusSyncing),
|
||||
makeW(StatusPaused),
|
||||
makeW(StatusUnknown),
|
||||
makeW(StatusError),
|
||||
}
|
||||
assert.Equal(t, StatusSummary{Healthy: 1, Error: 2, Syncing: 1, Paused: 1, Unknown: 1}, CountByStatus(workflows))
|
||||
})
|
||||
|
||||
t.Run("error phase overrides healthy target", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
w := Workflow{
|
||||
Status: WorkflowStatusObject{
|
||||
Source: WorkflowPhaseStatus{Status: StatusError},
|
||||
Artifact: WorkflowPhaseStatus{Status: StatusUnknown},
|
||||
Target: WorkflowPhaseStatus{Status: StatusHealthy},
|
||||
},
|
||||
}
|
||||
s := CountByStatus([]Workflow{w})
|
||||
assert.Equal(t, 1, s.Error)
|
||||
assert.Equal(t, 0, s.Healthy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeriveEdgeStackTargetState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
|
||||
return portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: id,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
statuses []portainer.EdgeStackStatusForEnv
|
||||
want Status
|
||||
}{
|
||||
{"empty", nil, StatusUnknown},
|
||||
{"all per-env status slices empty", []portainer.EdgeStackStatusForEnv{{EndpointID: 1}}, StatusUnknown},
|
||||
{"running → healthy", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusRunning)}, StatusHealthy},
|
||||
{"deploying → syncing", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusDeploying)}, StatusSyncing},
|
||||
{"paused deploying → paused", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusPausedDeploying)}, StatusPaused},
|
||||
{"error short-circuits", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusError)}, StatusError},
|
||||
{
|
||||
"error + running → error (short-circuit, order matters)",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusError),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusError,
|
||||
},
|
||||
{
|
||||
"syncing beats paused",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusPausedDeploying),
|
||||
ep(2, portainer.EdgeStackStatusDeploying),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
{
|
||||
"healthy does not downgrade syncing",
|
||||
[]portainer.EdgeStackStatusForEnv{
|
||||
ep(1, portainer.EdgeStackStatusDeploying),
|
||||
ep(2, portainer.EdgeStackStatusRunning),
|
||||
},
|
||||
StatusSyncing,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := deriveEdgeStackTargetState(tc.statuses)
|
||||
assert.Equal(t, tc.want, result.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
98
api/gitops/workflows/types.go
Normal file
98
api/gitops/workflows/types.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusHealthy Status = "healthy"
|
||||
StatusSyncing Status = "syncing"
|
||||
StatusError Status = "error"
|
||||
StatusPaused Status = "paused"
|
||||
StatusUnknown Status = "unknown"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeStack Type = "stack"
|
||||
TypeEdgeStack Type = "edgeStack"
|
||||
)
|
||||
|
||||
type DeploymentPlatform string
|
||||
|
||||
const (
|
||||
DeploymentPlatformDockerStandalone DeploymentPlatform = "dockerStandalone"
|
||||
DeploymentPlatformDockerSwarm DeploymentPlatform = "dockerSwarm"
|
||||
DeploymentPlatformKubernetes DeploymentPlatform = "kubernetes"
|
||||
)
|
||||
|
||||
func ParseStatus(s string) (Status, error) {
|
||||
switch Status(s) {
|
||||
case StatusHealthy, StatusSyncing, StatusError, StatusPaused, StatusUnknown:
|
||||
return Status(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown status %q", s)
|
||||
}
|
||||
|
||||
func ParseType(s string) (Type, error) {
|
||||
switch Type(s) {
|
||||
case TypeStack, TypeEdgeStack:
|
||||
return Type(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown type %q", s)
|
||||
}
|
||||
|
||||
func ParsePlatform(s string) (DeploymentPlatform, error) {
|
||||
switch DeploymentPlatform(s) {
|
||||
case DeploymentPlatformDockerStandalone, DeploymentPlatformDockerSwarm, DeploymentPlatformKubernetes:
|
||||
return DeploymentPlatform(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown platform %q", s)
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
|
||||
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
|
||||
// All three phases share the Status type; source and artifact only ever emit healthy, error, or unknown.
|
||||
type WorkflowPhaseStatus struct {
|
||||
Status Status `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowStatusObject is the structured status reported for a workflow.
|
||||
type WorkflowStatusObject struct {
|
||||
Source WorkflowPhaseStatus `json:"source"`
|
||||
Artifact WorkflowPhaseStatus `json:"artifact"`
|
||||
Target WorkflowPhaseStatus `json:"target"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type Type `json:"type"`
|
||||
Platform DeploymentPlatform `json:"platform"`
|
||||
Status WorkflowStatusObject `json:"status"`
|
||||
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
|
||||
Target Target `json:"target"`
|
||||
CreationDate int64 `json:"creationDate"`
|
||||
LastSyncDate int64 `json:"lastSyncDate"`
|
||||
}
|
||||
|
||||
type StatusSummary struct {
|
||||
Healthy int `json:"healthy"`
|
||||
Syncing int `json:"syncing"`
|
||||
Error int `json:"error"`
|
||||
Paused int `json:"paused"`
|
||||
Unknown int `json:"unknown"`
|
||||
}
|
||||
65
api/gitops/workflows/types_test.go
Normal file
65
api/gitops/workflows/types_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"healthy", "error", "syncing", "paused", "unknown"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := ParseStatus(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Status(valid), s)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseStatus("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"stack", "edgeStack"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tp, err := ParseType(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Type(valid), tp)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseType("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, valid := range []string{"dockerStandalone", "dockerSwarm", "kubernetes"} {
|
||||
t.Run(valid, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p, err := ParsePlatform(valid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DeploymentPlatform(valid), p)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid returns error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParsePlatform("garbage")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
service := NewService(true)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestExecutePingOperationFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips.InitFIPS(false)
|
||||
|
||||
host := "http://localhost:1"
|
||||
@@ -36,6 +37,7 @@ func TestExecutePingOperationFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPingOperation(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add(portainer.PortainerAgentHeader, "1")
|
||||
}))
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
gcsrf "github.com/gorilla/csrf"
|
||||
@@ -17,37 +19,96 @@ import (
|
||||
|
||||
const csrfSkipHeader = "X-CSRF-Token-Skip"
|
||||
|
||||
// SkipCSRFToken signals that the X-CSRF-Token header should not be sent in the response.
|
||||
// Deprecated: only meaningful when the "legacy-csrf" feature flag is enabled.
|
||||
func SkipCSRFToken(w http.ResponseWriter) {
|
||||
w.Header().Set(csrfSkipHeader, "1")
|
||||
}
|
||||
|
||||
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
|
||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||
// DOCKER_EXTENSION=1 is set in build/docker-extension/docker-compose.yml
|
||||
isDockerDesktopExtension := false
|
||||
if val, ok := os.LookupEnv("DOCKER_EXTENSION"); ok && val == "1" {
|
||||
isDockerDesktopExtension = true
|
||||
}
|
||||
|
||||
handler = withSendCSRFToken(handler)
|
||||
if featureflags.IsEnabled("legacy-csrf") {
|
||||
return withLegacyProtect(handler, trustedOrigins, isDockerDesktopExtension)
|
||||
}
|
||||
|
||||
cop := http.NewCrossOriginProtection()
|
||||
for _, origin := range trustedOrigins {
|
||||
if err := cop.AddTrustedOrigin(origin); err != nil {
|
||||
return nil, fmt.Errorf("failed to add trusted origin %q: %w", origin, err)
|
||||
}
|
||||
}
|
||||
|
||||
cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Error().Err(cop.Check(r)).
|
||||
Str("request_url", r.URL.String()).
|
||||
Str("host", r.Host).
|
||||
Str("origin", r.Header.Get("Origin")).
|
||||
Str("sec_fetch_site", r.Header.Get("Sec-Fetch-Site")).
|
||||
Strs("trusted_origins", trustedOrigins).
|
||||
Msg("CSRF check failed")
|
||||
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
}))
|
||||
|
||||
protected := cop.Handler(handler)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if skip {
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
protected.ServeHTTP(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacyProtect(handler http.Handler, trustedOrigins []string, isDockerDesktopExtension bool) (http.Handler, error) {
|
||||
handler = withLegacySendCSRFToken(handler)
|
||||
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
// gorilla/csrf compares referer.Host against trusted origin entries, so it
|
||||
// needs bare host[:port] values rather than full scheme://host[:port] origins.
|
||||
legacyOrigins := make([]string, len(trustedOrigins))
|
||||
for i, origin := range trustedOrigins {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trusted origin %q: %w", origin, err)
|
||||
}
|
||||
|
||||
legacyOrigins[i] = parsed.Host
|
||||
}
|
||||
|
||||
handler = gcsrf.Protect(
|
||||
token,
|
||||
gcsrf.Path("/"),
|
||||
gcsrf.Secure(false),
|
||||
gcsrf.TrustedOrigins(trustedOrigins),
|
||||
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
|
||||
gcsrf.TrustedOrigins(legacyOrigins),
|
||||
gcsrf.ErrorHandler(withLegacyErrorHandler(trustedOrigins)),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||
return withLegacySkipCSRF(handler, isDockerDesktopExtension), nil
|
||||
}
|
||||
|
||||
func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacySendCSRFToken(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := negroni.NewResponseWriter(w)
|
||||
|
||||
@@ -67,7 +128,8 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacySkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
|
||||
if err != nil {
|
||||
@@ -84,7 +146,8 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
|
||||
})
|
||||
}
|
||||
|
||||
func withErrorHandler(trustedOrigins []string) http.Handler {
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacyErrorHandler(trustedOrigins []string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := gcsrf.FailureReason(r)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user