Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6486934a33 | |||
| 44af81a465 | |||
| f06b9eadac | |||
| 2750028eba | |||
| 6d2e35f907 | |||
| 21498532e0 | |||
| bffbbfc32e | |||
| 4581a7760f | |||
| 71323f43fa | |||
| 3a9365ea85 | |||
| 11b18b0878 | |||
| 1fbb98d387 | |||
| 9083c09fd1 | |||
| c954943865 | |||
| d54ccd5502 | |||
| 7e526c4df7 | |||
| bf56a6c913 | |||
| 6b1b6ff998 | |||
| 9183be7a8c | |||
| 7f83d15812 | |||
| f926b61978 | |||
| cc5f790f98 | |||
| 40b210a708 | |||
| 8be327f087 | |||
| f498d76c4f | |||
| a1fa77cbe4 | |||
| e11c2e9611 | |||
| 6e03a801e6 | |||
| 6024a97892 | |||
| 1549b36103 | |||
| 7bb3e0f7a6 | |||
| 49cc901dc3 | |||
| 31dd62fbcc | |||
| a7d2d134d0 | |||
| 14f25f1e88 | |||
| 69b8b8373f | |||
| 0b075e6e10 | |||
| b468160606 | |||
| a42e96b650 | |||
| 5a19f66a37 | |||
| b271026188 | |||
| d168e3c912 | |||
| 0b6ebd70e0 | |||
| 127e03552a | |||
| f2bdfc6eff | |||
| 5db67faa00 | |||
| f9dcfcb435 | |||
| 1d1bb526d0 | |||
| c8fe8ba4fd | |||
| d3692a5a5f | |||
| 3407811c28 | |||
| b71db0d1f1 | |||
| 5e5e85ff3a | |||
| 65d82e12ee | |||
| d9e730e0a5 | |||
| 21eb20b35e | |||
| f85a7ea24c | |||
| 6aacb61c87 | |||
| bb2c75ba93 | |||
| 16536c8a71 | |||
| 9fcac1ab4f | |||
| ae24ad4693 | |||
| 0f721b60a9 | |||
| e8b49f53e1 | |||
| 27531a802b | |||
| 4bbf0ce0c0 | |||
| e0c22ea3eb | |||
| b7eb2ba068 | |||
| affdb69568 | |||
| 763b7da65c | |||
| 42e9165347 | |||
| 16dd08a359 | |||
| 936494615c | |||
| 5769c0b98e | |||
| b7e1caa8c6 | |||
| e02ae6b2fb | |||
| d9f131a2c5 | |||
| ad1f7dbaa5 | |||
| aa6da0f6d3 | |||
| 376071e408 | |||
| d3544fb9b3 | |||
| c8497b3944 | |||
| 5aa92b8413 | |||
| bccb6694d4 | |||
| 506a11c658 | |||
| bdc315a59d | |||
| ec7d3bddfc | |||
| 762c1ccf28 | |||
| 8e44c8fa06 | |||
| 20db102327 | |||
| 1643cb8165 | |||
| 49e623dfeb | |||
| a1208974ac | |||
| d611087513 | |||
| ac7cb2ee19 | |||
| f866572cbf | |||
| 4c6942f60b | |||
| d939897524 | |||
| 66c5589fd7 | |||
| 379b1d611b | |||
| f16221f385 | |||
| 9b82560270 | |||
| 7271af03e6 | |||
| 4d564bbce2 | |||
| d7afdf214b | |||
| 18e445ea02 | |||
| cb70c705a3 | |||
| 9a77eb9872 | |||
| ec82f646a0 | |||
| 2f0e384240 | |||
| 19a1426869 | |||
| cc5cd8db6b | |||
| e384e2edda | |||
| dca044873f | |||
| 8aadddcc68 | |||
| 2e95229c51 | |||
| 8a1d02c23f |
+9
-2
@@ -114,6 +114,9 @@ overrides:
|
||||
'@typescript-eslint/explicit-module-boundary-types': off
|
||||
'@typescript-eslint/no-unused-vars': 'error'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'error'
|
||||
'consistent-return': 'off'
|
||||
'default-case': off
|
||||
'jsx-a11y/label-has-associated-control':
|
||||
- error
|
||||
- assert: either
|
||||
@@ -139,15 +142,18 @@ overrides:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
plugins:
|
||||
- '@vitest'
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
- 'plugin:@vitest/legacy-recommended'
|
||||
env:
|
||||
'vitest/env': true
|
||||
'@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
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
@@ -155,3 +161,4 @@ overrides:
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'storybook/no-renderer-packages': off
|
||||
|
||||
@@ -94,10 +94,13 @@ 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.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
|
||||
@@ -6,7 +6,7 @@ linters:
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
+38
-18
@@ -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';
|
||||
|
||||
@@ -9,20 +10,38 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -67,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,
|
||||
@@ -83,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;
|
||||
|
||||
+17
-18
@@ -1,9 +1,9 @@
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
@@ -21,31 +21,30 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
const preview: Preview = {
|
||||
decorators: (Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
];
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
export default preview;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
see also:
|
||||
|
||||
- docs/guidelines/server-architecture.md
|
||||
- docs/guidelines/go-conventions.md
|
||||
- docs/guidelines/typescript-conventions.md
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.25.8 (for backend)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
make build # Build both client and server
|
||||
make build-client # Build React/AngularJS frontend
|
||||
make build-server # Build Go binary
|
||||
make build-image # Build Docker image
|
||||
|
||||
# Development
|
||||
make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
# 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)
|
||||
make test-server # Backend tests only
|
||||
make lint # Lint all code
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Development Servers
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -108,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
|
||||
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
|
||||
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
+48
-11
@@ -252,8 +252,9 @@ func (service *Service) startTunnelVerificationLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// checkTunnels finds the first tunnel that has not had any activity recently
|
||||
// and attempts to take a snapshot, then closes it and returns
|
||||
// checkTunnels finds tunnels that need snapshots and processes them one at a time.
|
||||
// For active tunnels missing an initial snapshot, it takes one without closing the tunnel.
|
||||
// For tunnels idle past activeTimeout, it snapshots and closes them.
|
||||
func (service *Service) checkTunnels() {
|
||||
service.mu.RLock()
|
||||
|
||||
@@ -264,12 +265,32 @@ func (service *Service) checkTunnels() {
|
||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
||||
Msg("environment tunnel monitoring")
|
||||
|
||||
tunnelPort := tunnel.Port
|
||||
|
||||
if !tunnel.HasSnapshot && elapsed < activeTimeout {
|
||||
service.mu.RUnlock()
|
||||
|
||||
if endpointHasSnapshot(service.dataStore, endpointID) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Msg("taking initial snapshot for active Edge environment")
|
||||
|
||||
if service.snapshotAndLog(endpointID, tunnelPort) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
tunnelPort := tunnel.Port
|
||||
|
||||
service.mu.RUnlock()
|
||||
|
||||
log.Debug().
|
||||
@@ -278,13 +299,7 @@ func (service *Service) checkTunnels() {
|
||||
Float64("timeout_seconds", activeTimeout.Seconds()).
|
||||
Msg("last activity timeout exceeded")
|
||||
|
||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
}
|
||||
|
||||
service.snapshotAndLog(endpointID, tunnelPort)
|
||||
service.close(endpointID)
|
||||
|
||||
return
|
||||
@@ -293,6 +308,28 @@ func (service *Service) checkTunnels() {
|
||||
service.mu.RUnlock()
|
||||
}
|
||||
|
||||
func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool {
|
||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if tun, ok := service.activeTunnels[endpointID]; ok {
|
||||
tun.HasSnapshot = true
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
|
||||
+164
-3
@@ -2,6 +2,7 @@ package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -18,13 +19,36 @@ func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
type mockSnapshotService struct {
|
||||
snapshotFn func(endpoint *portainer.Endpoint) error
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) Start() {}
|
||||
|
||||
func (m *mockSnapshotService) SetSnapshotInterval(_ string) error { return nil }
|
||||
|
||||
func (m *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
|
||||
if m.snapshotFn != nil {
|
||||
return m.snapshotFn(endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) FillSnapshotData(_ *portainer.Endpoint, _ bool) error { return nil }
|
||||
|
||||
func newEdgeEndpoint(id portainer.EndpointID) *portainer.Endpoint {
|
||||
return &portainer.Endpoint{
|
||||
ID: id,
|
||||
EdgeID: "test-edge-id",
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
UserTrusted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
@@ -57,3 +81,140 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
require.NoError(t, srv.Shutdown(context.Background()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
}
|
||||
|
||||
func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
err := s.Open(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(2)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snap := &portainer.Snapshot{
|
||||
EndpointID: endpoint.ID,
|
||||
Docker: &portainer.DockerSnapshot{},
|
||||
}
|
||||
err = store.Snapshot().Create(snap)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50003,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(3)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50000,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(4)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
return errors.New("snapshot failed")
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50001,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot")
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(5)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50002,
|
||||
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -237,3 +238,12 @@ func encryptCredentials(username, password, key string) (string, error) {
|
||||
|
||||
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
|
||||
}
|
||||
|
||||
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
|
||||
s, err := dataStore.Snapshot().Read(endpointID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return s.Docker != nil || s.Kubernetes != nil
|
||||
}
|
||||
|
||||
+9
-2
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
flags.TLSKey = tlsKeyFlag.String()
|
||||
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
||||
|
||||
flags.KubectlShellImage = kingpin.Flag(
|
||||
var hasKubectlShellImageFlag bool
|
||||
kubectlShellImageFlag := kingpin.Flag(
|
||||
"kubectl-shell-image",
|
||||
"Kubectl shell image",
|
||||
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
|
||||
).Envar(portainer.KubectlShellImageEnvVar).
|
||||
Default(portainer.DefaultKubectlShellImage).
|
||||
IsSetByUser(&hasKubectlShellImageFlag)
|
||||
flags.KubectlShellImage = kubectlShellImageFlag.String()
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
|
||||
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
|
||||
|
||||
if !filepath.IsAbs(*flags.Assets) {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
zerolog "github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
|
||||
require.True(t, *opts.EnableEdgeComputeFeatures)
|
||||
}
|
||||
|
||||
func TestParseKubectlShellImageFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
envVars map[string]string
|
||||
expectedKubectlShellImageSet bool
|
||||
expectedKubectlShellFlag string
|
||||
}{
|
||||
{
|
||||
name: "no flag, no env var",
|
||||
expectedKubectlShellImageSet: false,
|
||||
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
|
||||
},
|
||||
{
|
||||
name: "explicit flag",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
{
|
||||
name: "env var",
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v3",
|
||||
},
|
||||
{
|
||||
name: "both env var and flag set",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.args == nil {
|
||||
tc.args = []string{"portainer"}
|
||||
}
|
||||
setOsArgs(t, tc.args)
|
||||
|
||||
for k, v := range tc.envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
flags, err := Service{}.ParseFlags("test-version")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
|
||||
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTLSFlags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -248,6 +248,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||
|
||||
if flags.KubectlShellImageSet {
|
||||
settings.KubectlShellImage = *flags.KubectlShellImage
|
||||
}
|
||||
|
||||
if *flags.Labels != nil {
|
||||
settings.BlackListedLabels = *flags.Labels
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -12,7 +15,7 @@ import (
|
||||
const secretFileName = "secret.txt"
|
||||
|
||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||
err := os.WriteFile(secretPath, []byte(password), 0600)
|
||||
err := os.WriteFile(secretPath, []byte(password), 0o600)
|
||||
require.NoError(t, err)
|
||||
return secretPath
|
||||
}
|
||||
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||
require.Len(t, encryptionKey, 32)
|
||||
}
|
||||
|
||||
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
|
||||
const existingImage = "existing-image:v1"
|
||||
const newImage = "new-image:v2"
|
||||
|
||||
emptyString := ""
|
||||
falseBool := false
|
||||
var emptyLabels []portainer.Pair
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
imageSet bool
|
||||
flagImage string
|
||||
expectedKubectlShellImage string
|
||||
}{
|
||||
{
|
||||
name: "flag not set — DB image unchanged",
|
||||
imageSet: false,
|
||||
flagImage: portainer.DefaultKubectlShellImage,
|
||||
expectedKubectlShellImage: existingImage,
|
||||
},
|
||||
{
|
||||
name: "flag set — DB image updated",
|
||||
imageSet: true,
|
||||
flagImage: newImage,
|
||||
expectedKubectlShellImage: newImage,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(
|
||||
testhelpers.WithSettingsService(&portainer.Settings{
|
||||
KubectlShellImage: existingImage,
|
||||
}),
|
||||
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
|
||||
)
|
||||
|
||||
flags := &portainer.CLIFlags{
|
||||
SnapshotInterval: &emptyString,
|
||||
Logo: &emptyString,
|
||||
EnableEdgeComputeFeatures: &falseBool,
|
||||
Templates: &emptyString,
|
||||
Labels: &emptyLabels,
|
||||
HTTPDisabled: &falseBool,
|
||||
HTTPEnabled: &falseBool,
|
||||
}
|
||||
flags.KubectlShellImage = &tc.flagImage
|
||||
flags.KubectlShellImageSet = tc.imageSet
|
||||
|
||||
err := updateSettingsFromFlags(store, flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBSecretPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
keyFilenameFlag string
|
||||
|
||||
+3
-1
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS {
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
ErrDBImportFailed = errors.New("importing backup failed")
|
||||
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
||||
)
|
||||
|
||||
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
service: service,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// Settings retrieve the ssl settings object.
|
||||
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
service *Service
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
|
||||
// Settings retrieve the settings object.
|
||||
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
err := service.tx.GetObject(BucketName, []byte(key), &settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings persists a Settings object.
|
||||
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||
return service.tx.UpdateObject(BucketName, []byte(key), settings)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
||||
KubectlShellImage: *store.flags.KubectlShellImage,
|
||||
|
||||
IsDockerDesktopExtension: isDDExtention,
|
||||
EnforceEdgeID: true,
|
||||
}
|
||||
|
||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
||||
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
|
||||
|
||||
// In this particular instance we should log a fatal error
|
||||
if m.CurrentDBEdition() != portainer.PortainerCE {
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Stack() dataservices.StackService {
|
||||
return tx.store.StackService.Tx(tx.tx)
|
||||
|
||||
@@ -613,7 +613,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.3",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -942,7 +942,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.39.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
@@ -35,8 +36,10 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
@@ -44,8 +47,17 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
@@ -58,7 +61,6 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
expected := ContainerStats{}
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, expected, stats)
|
||||
assert.Equal(t, expected.Running, stats.Running)
|
||||
assert.Equal(t, expected.Stopped, stats.Stopped)
|
||||
assert.Equal(t, expected.Healthy, stats.Healthy)
|
||||
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
|
||||
@@ -77,6 +77,9 @@ type (
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig portainer.HelmConfig
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
|
||||
+11
-15
@@ -66,7 +66,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
},
|
||||
ForceRecreate: options.ForceRecreate,
|
||||
AbortOnContainerExit: options.AbortOnContainerExit,
|
||||
@@ -97,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
},
|
||||
Remove: options.Remove,
|
||||
Args: options.Args,
|
||||
@@ -146,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
})
|
||||
return errors.Wrap(err, "failed to pull images of the stack")
|
||||
}
|
||||
@@ -229,7 +229,12 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
|
||||
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
|
||||
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
|
||||
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
|
||||
// intentionally performs no DB writes to avoid write-lock contention when called inside
|
||||
// an active BoltDB write transaction.
|
||||
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
|
||||
var authConfigs []types.AuthConfig
|
||||
|
||||
for _, r := range registries {
|
||||
@@ -242,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
|
||||
if r.Authentication {
|
||||
var err error
|
||||
|
||||
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
|
||||
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -254,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
|
||||
return authConfigs
|
||||
}
|
||||
|
||||
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
|
||||
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to validate registry token. Skip logging with this registry.")
|
||||
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(registry)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
@@ -94,3 +95,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
|
||||
}
|
||||
|
||||
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns empty slice for empty input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
|
||||
require.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
registries := []portainer.Registry{
|
||||
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
|
||||
}
|
||||
result := portainerRegistriesToAuthConfigs(registries)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "registry.example.com", result[0].ServerAddress)
|
||||
require.Equal(t, "user", result[0].Username)
|
||||
require.Equal(t, "pass", result[0].Password)
|
||||
})
|
||||
|
||||
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
registries := []portainer.Registry{
|
||||
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
|
||||
}
|
||||
result := portainerRegistriesToAuthConfigs(registries)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "user", result[0].Username)
|
||||
require.Equal(t, "pass", result[0].Password)
|
||||
})
|
||||
|
||||
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
registries := []portainer.Registry{
|
||||
{
|
||||
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||
Username: "AKIAIOSFODNN7EXAMPLE",
|
||||
Password: "secretkey",
|
||||
Authentication: true,
|
||||
Type: portainer.EcrRegistry,
|
||||
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||
AccessToken: "AWS:ecr-password",
|
||||
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
|
||||
},
|
||||
}
|
||||
result := portainerRegistriesToAuthConfigs(registries)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "AWS", result[0].Username)
|
||||
require.Equal(t, "ecr-password", result[0].Password)
|
||||
})
|
||||
|
||||
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
registries := []portainer.Registry{
|
||||
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
|
||||
{
|
||||
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||
Authentication: true,
|
||||
Type: portainer.EcrRegistry,
|
||||
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||
AccessToken: "no-colon-token",
|
||||
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
|
||||
},
|
||||
}
|
||||
result := portainerRegistriesToAuthConfigs(registries)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "valid.example.com", result[0].ServerAddress)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
|
||||
operations := map[string]func(context.Context, []string) (string, error){
|
||||
"apply": client.ApplyDynamic,
|
||||
"delete": client.Delete,
|
||||
"delete": client.DeleteDynamic,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
|
||||
@@ -61,7 +61,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
||||
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, ®istry)
|
||||
username, password, err := getEffectiveRegUsernamePassword(®istry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
+52
-21
@@ -3,23 +3,42 @@ 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"
|
||||
"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"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
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
|
||||
}
|
||||
@@ -30,8 +49,33 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
|
||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
|
||||
gitOptions := git.CloneOptions{
|
||||
gitOptions := &git.CloneOptions{
|
||||
URL: opt.repositoryUrl,
|
||||
Depth: opt.depth,
|
||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||
@@ -43,23 +87,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
|
||||
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return c.Download(ctx, dst, gitOptions)
|
||||
}
|
||||
|
||||
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
|
||||
@@ -73,11 +101,12 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
|
||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||
}
|
||||
|
||||
refs, err := remote.List(listOptions)
|
||||
refs, err := remote.ListContext(ctx, listOptions)
|
||||
if err != nil {
|
||||
if err.Error() == "authentication required" {
|
||||
return "", gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return "", errors.Wrap(err, "failed to list repository refs")
|
||||
}
|
||||
|
||||
@@ -159,6 +188,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
|
||||
if ref.Name().String() == "HEAD" {
|
||||
continue
|
||||
}
|
||||
|
||||
ret = append(ret, ref.Name().String())
|
||||
}
|
||||
|
||||
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
|
||||
} else if errMsg == "authentication required" {
|
||||
return gittypes.ErrAuthenticationFailure
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+113
-3
@@ -3,13 +3,17 @@ 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"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -18,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, 0755)
|
||||
if err != nil {
|
||||
@@ -53,7 +57,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
||||
require.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_cloneRepository(t *testing.T) {
|
||||
@@ -146,6 +150,112 @@ 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) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
|
||||
+13
-1
@@ -185,7 +185,19 @@ func (service *Service) LatestCommitID(
|
||||
referenceName: referenceName,
|
||||
}
|
||||
|
||||
return service.repoManager(options.baseOption).latestCommitID(context.TODO(), options)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
id, err := service.repoManager(options.baseOption).latestCommitID(ctx, options)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
log.Error().Str("url", repositoryURL).Msg("git fetch timed out after 1 minute")
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListRefs will list target repository's references without cloning the repository
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
type GitCredentialAuthType int
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -74,6 +75,11 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
}
|
||||
|
||||
if err := cloneGitRepository(gitService, cloneParams); err != nil {
|
||||
if enableVersionFolder {
|
||||
if removeErr := os.RemoveAll(toDir); removeErr != nil {
|
||||
log.Warn().Err(removeErr).Str("dir", toDir).Msg("failed to remove partial clone directory")
|
||||
}
|
||||
}
|
||||
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -25,7 +26,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
|
||||
return "", errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
username, err := handler.OAuthService.Authenticate(code, settings)
|
||||
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
|
||||
}
|
||||
|
||||
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("OAuth authentication error")
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
||||
}
|
||||
|
||||
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||
}
|
||||
|
||||
canEdit := userCanEditTemplate(customTemplate, securityContext)
|
||||
hasAccess := false
|
||||
|
||||
if resourceControl != nil {
|
||||
customTemplate.ResourceControl = resourceControl
|
||||
|
||||
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
|
||||
return m.TeamID
|
||||
})
|
||||
|
||||
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
|
||||
}
|
||||
|
||||
if canEdit || hasAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
entryPath := customTemplate.EntryPoint
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomTemplateFile(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
templateContent := "some template content"
|
||||
templateEntrypoint := "entrypoint"
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||
2: portainer.AccessPolicy{RoleID: 0},
|
||||
3: portainer.AccessPolicy{RoleID: 0},
|
||||
}}))
|
||||
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
|
||||
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
|
||||
|
||||
// template 1
|
||||
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
// template 2
|
||||
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
|
||||
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
|
||||
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
|
||||
}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
|
||||
r = mux.SetURLVars(r, map[string]string{"id": templateID})
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
return rr, handler.customTemplateFile(rr, r)
|
||||
}
|
||||
|
||||
t.Run("unknown id should get not found error", func(t *testing.T) {
|
||||
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusNotFound, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin should access adminonly template", func(t *testing.T) {
|
||||
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access adminonly template", func(t *testing.T) {
|
||||
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("std should access template via direct user access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should access template via team access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access template without access", func(t *testing.T) {
|
||||
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -46,7 +47,7 @@ func (handler *Handler) recreate(w http.ResponseWriter, r *http.Request) *httper
|
||||
|
||||
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
newContainer, err := handler.containerService.Recreate(r.Context(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
|
||||
newContainer, err := handler.containerService.Recreate(context.TODO(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Error recreating container", err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type StackViewModel struct {
|
||||
Name string
|
||||
IsExternal bool
|
||||
Type portainer.StackType
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
|
||||
@@ -56,6 +57,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
||||
Name: name,
|
||||
IsExternal: true,
|
||||
Type: portainer.DockerComposeStack,
|
||||
Labels: container.Labels,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
||||
Name: name,
|
||||
IsExternal: true,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
Labels: service.Spec.Labels,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +82,10 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
||||
|
||||
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
|
||||
func(item StackViewModel) (*portainer.ResourceControl, error) {
|
||||
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
|
||||
if item.InternalStack != nil {
|
||||
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
|
||||
}
|
||||
return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
dockerconsts "github.com/portainer/portainer/api/docker/consts"
|
||||
"github.com/portainer/portainer/api/docker/consts"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
||||
containers := []types.Container{
|
||||
{
|
||||
Labels: map[string]string{
|
||||
dockerconsts.ComposeStackNameLabel: "stack1",
|
||||
consts.ComposeStackNameLabel: "stack1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{
|
||||
dockerconsts.ComposeStackNameLabel: "stack2",
|
||||
consts.ComposeStackNameLabel: "stack2",
|
||||
"io.portainer.accesscontrol.public": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -43,7 +44,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
||||
Spec: swarm.ServiceSpec{
|
||||
Annotations: swarm.Annotations{
|
||||
Labels: map[string]string{
|
||||
dockerconsts.SwarmStackNameLabel: "stack3",
|
||||
consts.SwarmStackNameLabel: "stack3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -65,14 +66,16 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
||||
is.NoError(tx.Stack().Create(&stack1))
|
||||
is.NoError(tx.Stack().Create(&portainer.Stack{
|
||||
ID: 2,
|
||||
Name: "stack2",
|
||||
Name: "stack2", // stack 2 on env 2
|
||||
EndpointID: 2,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
}))
|
||||
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||
is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
// testing admin user
|
||||
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
||||
Name: "stack2",
|
||||
IsExternal: true,
|
||||
Type: portainer.DockerComposeStack,
|
||||
Labels: map[string]string{
|
||||
consts.ComposeStackNameLabel: "stack2",
|
||||
"io.portainer.accesscontrol.public": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stack3",
|
||||
IsExternal: true,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
Labels: map[string]string{
|
||||
consts.SwarmStackNameLabel: "stack3",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, expectedStacks, stacksList)
|
||||
return nil
|
||||
}))
|
||||
|
||||
// testing standard user
|
||||
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserID: 2,
|
||||
}, environment.ID, containers, services)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, stacksList, 1)
|
||||
|
||||
expectedStacks := []StackViewModel{
|
||||
{
|
||||
Name: "stack2",
|
||||
IsExternal: true,
|
||||
Type: portainer.DockerComposeStack,
|
||||
Labels: map[string]string{
|
||||
consts.ComposeStackNameLabel: "stack2",
|
||||
"io.portainer.accesscontrol.public": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
_, err := tx.EdgeGroup().Read(ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EdgeGroup().Exists(ID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
|
||||
|
||||
if len(payload.EdgeGroups) > 0 {
|
||||
for _, edgeGroupID := range payload.EdgeGroups {
|
||||
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
|
||||
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
|
||||
return dserrors.ErrObjectNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
|
||||
_, err := tx.EndpointGroup().Read(endpointGroupID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
|
||||
// Environment(Endpoint) group name
|
||||
Name string `example:"my-environment-group"`
|
||||
// Environment(Endpoint) group description
|
||||
Description string `example:"description"`
|
||||
Description *string `example:"description"`
|
||||
// List of environment(endpoint) identifiers that will be part of this group
|
||||
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
|
||||
// List of tag identifiers associated to the environment(endpoint) group
|
||||
TagIDs []portainer.TagID `example:"3,4"`
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
endpointGroup.Name = payload.Name
|
||||
}
|
||||
|
||||
if payload.Description != "" {
|
||||
endpointGroup.Description = payload.Description
|
||||
if payload.Description != nil {
|
||||
endpointGroup.Description = *payload.Description
|
||||
}
|
||||
|
||||
tagsChanged := false
|
||||
@@ -147,11 +149,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
|
||||
// Update flag with endpoint and continue
|
||||
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
}
|
||||
}(endpoint.ID, endpointGroup.ID)
|
||||
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
|
||||
}
|
||||
|
||||
if tagsChanged {
|
||||
// Handle associated endpoints updates
|
||||
endpointsChanged := false
|
||||
if payload.AssociatedEndpoints != nil {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
// Build a set of the new endpoint IDs for quick lookup
|
||||
newEndpointSet := make(map[portainer.EndpointID]bool)
|
||||
for _, id := range payload.AssociatedEndpoints {
|
||||
newEndpointSet[id] = true
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
wasInGroup := endpoint.GroupID == endpointGroup.ID
|
||||
shouldBeInGroup := newEndpointSet[endpoint.ID]
|
||||
|
||||
if wasInGroup && !shouldBeInGroup {
|
||||
// Remove from group (move to Unassigned)
|
||||
endpoint.GroupID = portainer.EndpointGroupID(1)
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
} else if !wasInGroup && shouldBeInGroup {
|
||||
// Add to group
|
||||
endpoint.GroupID = endpointGroup.ID
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
|
||||
if tagsChanged && !endpointsChanged {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
|
||||
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
|
||||
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
||||
|
||||
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
|
||||
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to update user authorizations")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
|
||||
}
|
||||
@@ -179,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
err = tx.Tag().Update(tagID, tag)
|
||||
}
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
log.Warn().Err(err).Msg("Unable to find tag inside the database")
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
|
||||
@@ -227,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
|
||||
if endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
|
||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||
|
||||
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
|
||||
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
|
||||
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
excludeGroupIds []portainer.EndpointGroupID
|
||||
edgeGroupIds []portainer.EdgeGroupID
|
||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||
}
|
||||
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
tagIds: tagIDs,
|
||||
endpointIds: endpointIDs,
|
||||
excludeIds: excludeIDs,
|
||||
excludeGroupIds: excludeGroupIDs,
|
||||
tagsPartialMatch: tagsPartialMatch,
|
||||
groupIds: groupIDs,
|
||||
status: status,
|
||||
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.excludeGroupIds) > 0 {
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.groupIds) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
|
||||
runTests(tests, t, handler, environments)
|
||||
}
|
||||
|
||||
func Test_Filter_excludeGroupIDs(t *testing.T) {
|
||||
groupA := portainer.EndpointGroupID(10)
|
||||
groupB := portainer.EndpointGroupID(20)
|
||||
groupC := portainer.EndpointGroupID(30)
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
|
||||
}
|
||||
|
||||
handler := setupFilterTest(t, endpoints)
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
title: "should exclude endpoints in groupA",
|
||||
expected: []portainer.EndpointID{3, 4, 5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should exclude endpoints in groupA and groupB",
|
||||
expected: []portainer.EndpointID{5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should return all endpoints when excludeGroupIds is empty",
|
||||
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
|
||||
query: EnvironmentsQuery{},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
||||
n := 10000
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/agent_versions",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
|
||||
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.37.0
|
||||
// @version 2.39.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
||||
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
|
||||
@@ -177,6 +177,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
|
||||
@@ -2,8 +2,10 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -31,33 +33,23 @@ import (
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
|
||||
// @router /kubernetes/{id}/ingresscontrollers [get]
|
||||
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
|
||||
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
|
||||
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
|
||||
}
|
||||
|
||||
// Get endpoint from context (may have policies applied in-memory)
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
Type: "custom",
|
||||
})
|
||||
}
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses = []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
controllers = allowedControllers
|
||||
}
|
||||
|
||||
return response.JSON(w, controllers)
|
||||
}
|
||||
|
||||
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
|
||||
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace from request", err)
|
||||
}
|
||||
|
||||
currentControllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
|
||||
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
currentControllers = append(currentControllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
})
|
||||
}
|
||||
|
||||
kubernetesConfig := endpoint.Kubernetes.Configuration
|
||||
existingClasses := kubernetesConfig.IngressClasses
|
||||
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
|
||||
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
|
||||
controllers := models.K8sIngressControllers{}
|
||||
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers."
|
||||
// @router /kubernetes/{id}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
|
||||
return httperror.BadRequest("Unable to retrieve environment", err)
|
||||
}
|
||||
|
||||
payload := models.K8sIngressControllers{}
|
||||
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use policy-applied endpoint for this check since it affects the response.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
})
|
||||
}
|
||||
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.NotFound("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
|
||||
return httperror.BadRequest("Unable to decode and validate the request payload", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
PayloadLoop:
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
for _, ns := range updatedClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
break
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
|
||||
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
|
||||
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
|
||||
adminRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
|
||||
|
||||
// Use registry-specific access bouncer for inspect and repositories endpoints
|
||||
registryAccessRouter := handler.NewRoute().Subrouter()
|
||||
@@ -82,7 +83,6 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
|
||||
authenticatedRouter := handler.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
authenticatedRouter.Handle("/registries/ping", httperror.LoggerHandler(handler.pingRegistry)).Methods(http.MethodPost)
|
||||
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
|
||||
}
|
||||
|
||||
type accessGuard interface {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
@@ -51,47 +52,52 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return httperror.InternalServerError("Unable to remove the registry from the database", err)
|
||||
}
|
||||
|
||||
handler.deleteKubernetesSecrets(registry)
|
||||
handler.deleteKubernetesSecrets(handler.DataStore, registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
|
||||
for endpointId, access := range registry.RegistryAccesses {
|
||||
if access.Namespaces != nil {
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
if access.Namespaces == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
if len(failedNamespaces) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedNamespaces) > 0 {
|
||||
if err := handler.PendingActionsService.Create(
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
if err := handler.PendingActionsService.Create(
|
||||
tx,
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
|
||||
// Make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
|
||||
@@ -192,28 +192,23 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
|
||||
// @router /stacks/create/swarm/repository [post]
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for name collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return stackExistsError(payload.Name)
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
return isUniqueStackName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
|
||||
_, err := tx.Stack().StackByWebhookID(webhookID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
|
||||
(stack.AutoUpdate == nil ||
|
||||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
// @produce json
|
||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||
// @param nodeName query string false "node name"
|
||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
||||
// @success 200
|
||||
// @failure 400
|
||||
// @failure 403
|
||||
|
||||
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
|
||||
// @produce json
|
||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||
// @param nodeName query string false "node name"
|
||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
||||
// @success 200
|
||||
// @failure 400
|
||||
// @failure 409
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
// @param podName query string true "name of the pod containing the container"
|
||||
// @param containerName query string true "name of the container"
|
||||
// @param command query string true "command to execute in the container"
|
||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
||||
// @success 200
|
||||
// @failure 400
|
||||
// @failure 403
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
|
||||
@@ -170,18 +170,23 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
|
||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
type PartialContainer struct {
|
||||
HostConfig struct {
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
Mounts []struct {
|
||||
Type string `json:"Type"`
|
||||
} `json:"Mounts"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
forbiddenResponse := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Body: http.NoBody,
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
@@ -230,7 +235,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
return nil, ErrContainerCapabilitiesForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
|
||||
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
|
||||
for _, bind := range partialContainer.HostConfig.Binds {
|
||||
if strings.HasPrefix(bind, "/") {
|
||||
return forbiddenResponse, ErrBindMountsForbidden
|
||||
@@ -238,6 +243,14 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
|
||||
for _, mount := range partialContainer.HostConfig.Mounts {
|
||||
if mount.Type == "bind" {
|
||||
return forbiddenResponse, ErrBindMountsForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
|
||||
@@ -252,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
|
||||
type PartialContainerUpdate struct {
|
||||
Devices []any `json:"Devices"`
|
||||
}
|
||||
|
||||
forbiddenResponse := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
}
|
||||
|
||||
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdminOrEndpointAdmin {
|
||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||
}
|
||||
|
||||
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partialUpdate := &PartialContainerUpdate{}
|
||||
if err := json.Unmarshal(body, partialUpdate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
|
||||
return forbiddenResponse, ErrDeviceMappingForbidden
|
||||
}
|
||||
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
|
||||
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
err := tx.User().Create(&admin)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.User().Create(®ularUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 1,
|
||||
Name: "test",
|
||||
SecuritySettings: portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
srv, version := mockDockerAPIServer(t, RoutesDefinition{
|
||||
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
transport := &Transport{
|
||||
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
|
||||
dataStore: ds,
|
||||
HTTPTransport: &http.Transport{},
|
||||
}
|
||||
|
||||
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
|
||||
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
|
||||
|
||||
makeRequest := func(token portainer.TokenData, body any) *http.Request {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
|
||||
req = req.WithContext(security.StoreTokenData(req, &token))
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// Admin bypasses security checks
|
||||
req := makeRequest(adminToken, map[string]any{
|
||||
"HostConfig": map[string]any{
|
||||
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
|
||||
},
|
||||
})
|
||||
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// HostConfig.Binds with an absolute path is blocked for regular users
|
||||
req = makeRequest(userToken, map[string]any{
|
||||
"HostConfig": map[string]any{
|
||||
"Binds": []string{"/:/host:ro"},
|
||||
},
|
||||
})
|
||||
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// HostConfig.Mounts with type bind is blocked for regular users
|
||||
req = makeRequest(userToken, map[string]any{
|
||||
"HostConfig": map[string]any{
|
||||
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
|
||||
},
|
||||
})
|
||||
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// HostConfig.Mounts with a non-bind type is allowed for regular users
|
||||
req = makeRequest(userToken, map[string]any{
|
||||
"HostConfig": map[string]any{
|
||||
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
|
||||
},
|
||||
})
|
||||
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package docker
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -18,6 +18,70 @@ import (
|
||||
|
||||
const serviceObjectIdentifier = "ID"
|
||||
|
||||
type partialServiceSpec struct {
|
||||
TaskTemplate struct {
|
||||
ContainerSpec struct {
|
||||
CapabilityAdd []string `json:"CapabilityAdd"`
|
||||
CapabilityDrop []string `json:"CapabilityDrop"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
Privileges *struct {
|
||||
Seccomp *struct{ Mode string } `json:"Seccomp"`
|
||||
AppArmor *struct{ Mode string } `json:"AppArmor"`
|
||||
} `json:"Privileges"`
|
||||
Mounts []struct {
|
||||
Type string `json:"Type"`
|
||||
VolumeOptions *struct {
|
||||
DriverConfig *struct {
|
||||
Options map[string]string `json:"Options"`
|
||||
} `json:"DriverConfig"`
|
||||
} `json:"VolumeOptions"`
|
||||
} `json:"Mounts"`
|
||||
} `json:"ContainerSpec"`
|
||||
} `json:"TaskTemplate"`
|
||||
}
|
||||
|
||||
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
|
||||
defer logs.CloseAndLogErr(request.Body)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := &partialServiceSpec{}
|
||||
if err := json.Unmarshal(body, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
containerSpec := spec.TaskTemplate.ContainerSpec
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
|
||||
return ErrContainerCapabilitiesForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
|
||||
return ErrSysCtlSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowBindMountsForRegularUsers {
|
||||
for _, mount := range containerSpec.Mounts {
|
||||
if mount.Type == "bind" {
|
||||
return ErrBindMountsForbidden
|
||||
}
|
||||
|
||||
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
|
||||
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
|
||||
return ErrBindMountsForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
@@ -90,20 +154,6 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
|
||||
type PartialService struct {
|
||||
TaskTemplate struct {
|
||||
ContainerSpec struct {
|
||||
Mounts []struct {
|
||||
Type string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forbiddenResponse := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
}
|
||||
|
||||
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
|
||||
}, err
|
||||
}
|
||||
|
||||
return transport.replaceRegistryAuthenticationHeader(request)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
|
||||
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partialService := &PartialService{}
|
||||
if err := json.Unmarshal(body, partialService); err != nil {
|
||||
if isAdminOrEndpointAdmin {
|
||||
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
|
||||
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
|
||||
if mount.Type == "bind" {
|
||||
return forbiddenResponse, errors.New("forbidden to use bind mounts")
|
||||
}
|
||||
}
|
||||
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
|
||||
}, err
|
||||
}
|
||||
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.replaceRegistryAuthenticationHeader(request)
|
||||
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const serviceCreationAPIVersion = "1.51"
|
||||
|
||||
type serviceCreationFixtures struct {
|
||||
dockerSrv *httptest.Server
|
||||
ds dataservices.DataStore
|
||||
stdUser portainer.User
|
||||
adminUser portainer.User
|
||||
endpointID portainer.EndpointID
|
||||
}
|
||||
|
||||
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
|
||||
t.Helper()
|
||||
|
||||
const serviceID = "some-service-id"
|
||||
|
||||
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
||||
w.Header().Add("Api-Version", serviceCreationAPIVersion)
|
||||
_, _ = w.Write([]byte{})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
data, err := json.Marshal(map[string]string{"ID": serviceID})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(dockerSrv.Close)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
f := &serviceCreationFixtures{
|
||||
dockerSrv: dockerSrv,
|
||||
ds: store,
|
||||
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
|
||||
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
|
||||
endpointID: portainer.EndpointID(1),
|
||||
}
|
||||
|
||||
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
err := tx.User().Create(&f.stdUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.User().Create(&f.adminUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
|
||||
t.Helper()
|
||||
|
||||
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
|
||||
ID: f.endpointID,
|
||||
Name: "test-env",
|
||||
SecuritySettings: settings,
|
||||
})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (f *serviceCreationFixtures) newTransport() *Transport {
|
||||
return &Transport{
|
||||
endpoint: &portainer.Endpoint{ID: f.endpointID},
|
||||
dataStore: f.ds,
|
||||
HTTPTransport: &http.Transport{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}))
|
||||
}
|
||||
|
||||
var (
|
||||
restrictiveSettings = portainer.EndpointSecuritySettings{
|
||||
AllowContainerCapabilitiesForRegularUsers: false,
|
||||
AllowSysctlSettingForRegularUsers: false,
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
}
|
||||
|
||||
permissiveSettings = portainer.EndpointSecuritySettings{
|
||||
AllowContainerCapabilitiesForRegularUsers: true,
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
}
|
||||
)
|
||||
|
||||
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
CapabilityAdd: []string{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
CapabilityDrop: []string{"MKNOD"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, permissiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
CapabilityAdd: []string{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
var spec swarm.ServiceSpec
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, permissiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
|
||||
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||
AllowContainerCapabilitiesForRegularUsers: true,
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
})
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, permissiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
CapabilityAdd: []string{"NET_ADMIN"},
|
||||
CapabilityDrop: []string{"MKNOD"},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
|
||||
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, permissiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
CapabilityAdd: []string{"NET_ADMIN"},
|
||||
Sysctls: map[string]string{"net.core.somaxconn": "128"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{
|
||||
Type: mount.TypeVolume,
|
||||
VolumeOptions: &mount.VolumeOptions{
|
||||
DriverConfig: &mount.Driver{
|
||||
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, permissiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{
|
||||
Type: mount.TypeVolume,
|
||||
VolumeOptions: &mount.VolumeOptions{
|
||||
DriverConfig: &mount.Driver{
|
||||
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{
|
||||
Type: mount.TypeVolume,
|
||||
VolumeOptions: &mount.VolumeOptions{
|
||||
DriverConfig: &mount.Driver{
|
||||
Options: map[string]string{"type": "tmpfs"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceCreationFixtures(t)
|
||||
f.setSecuritySettings(t, restrictiveSettings)
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{
|
||||
Type: mount.TypeVolume,
|
||||
VolumeOptions: &mount.VolumeOptions{
|
||||
DriverConfig: &mount.Driver{
|
||||
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
@@ -109,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
|
||||
"volumes": (*Transport).proxyVolumeRequest,
|
||||
}
|
||||
|
||||
type route struct {
|
||||
method string
|
||||
pattern *regexp.Regexp
|
||||
}
|
||||
|
||||
var adminOnlyRoutes = []route{
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
|
||||
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
|
||||
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
|
||||
}
|
||||
|
||||
func isAdminOnlyRoute(method string, path string) bool {
|
||||
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
|
||||
return method == r.method && r.pattern.MatchString(path)
|
||||
})
|
||||
}
|
||||
|
||||
// ProxyDockerRequest intercepts a Docker API request and apply logic based
|
||||
// on the requested operation.
|
||||
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
@@ -137,6 +160,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
|
||||
return proxyFunc(transport, request, unversionedPath)
|
||||
}
|
||||
|
||||
if isAdminOnlyRoute(request.Method, unversionedPath) {
|
||||
return transport.administratorOperation(request)
|
||||
}
|
||||
|
||||
return transport.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
@@ -261,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
|
||||
if action == "json" {
|
||||
return transport.rewriteOperation(request, transport.containerInspectOperation)
|
||||
}
|
||||
|
||||
if action == "update" {
|
||||
return transport.decorateContainerUpdateOperation(request, containerID)
|
||||
}
|
||||
|
||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||
} else if match, _ := path.Match("/containers/*", requestPath); match {
|
||||
// Handle /containers/{id} requests
|
||||
@@ -292,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
|
||||
if match, _ := path.Match("/services/*/*", requestPath); match {
|
||||
// Handle /services/{id}/{action} requests
|
||||
serviceID := path.Base(path.Dir(requestPath))
|
||||
action := path.Base(requestPath)
|
||||
|
||||
if action == "update" {
|
||||
return transport.decorateServiceUpdateOperation(request, serviceID)
|
||||
}
|
||||
|
||||
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||
return nil, err
|
||||
@@ -747,7 +784,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, response.StatusCode)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
|
||||
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
|
||||
return srv, version
|
||||
}
|
||||
|
||||
func TestTransport_adminProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
|
||||
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
|
||||
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&admin))
|
||||
require.NoError(t, tx.User().Create(&std1))
|
||||
require.NoError(t, tx.User().Create(&std2))
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
|
||||
}))
|
||||
|
||||
return nil
|
||||
}))
|
||||
srv, version := mockDockerAPIServer(t, RoutesDefinition{
|
||||
// allowed routes
|
||||
{http.MethodGet, "/plugins"}: nil,
|
||||
{http.MethodGet, "/plugins/xxx/json"}: nil,
|
||||
{http.MethodGet, "/plugins/privileges"}: nil,
|
||||
// admin routes ; see `adminOnlyRoutes`
|
||||
{http.MethodDelete, "/plugins/xxx"}: nil,
|
||||
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
|
||||
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
|
||||
{http.MethodPost, "/plugins/xxx/disable"}: nil,
|
||||
{http.MethodPost, "/plugins/pull"}: nil,
|
||||
{http.MethodPost, "/plugins/xxx/push"}: nil,
|
||||
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
|
||||
{http.MethodPost, "/plugins/xxx/set"}: nil,
|
||||
{http.MethodPost, "/plugins/create"}: nil,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
transport := &Transport{
|
||||
endpoint: &portainer.Endpoint{URL: srv.URL},
|
||||
dataStore: ds,
|
||||
HTTPTransport: &http.Transport{},
|
||||
}
|
||||
|
||||
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
|
||||
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
|
||||
req = req.WithContext(security.StoreTokenData(req, &token))
|
||||
require.NotNil(t, req)
|
||||
|
||||
return transport.ProxyDockerRequest(req)
|
||||
}
|
||||
|
||||
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
|
||||
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
|
||||
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodGet, "/plugins", adminToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodGet, "/plugins", std1Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodGet, "/plugins", std2Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
|
||||
{
|
||||
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
require.NoError(t, r.Body.Close())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_getRealResourceID(t *testing.T) {
|
||||
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
|
||||
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
const volumeObjectIdentifier = "ResourceID"
|
||||
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
|
||||
return utils.GetJSONObject(responseObject, "Labels")
|
||||
}
|
||||
|
||||
func CheckVolumeBodyRestrictions(request *http.Request) error {
|
||||
defer logs.CloseAndLogErr(request.Body)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var volumeCreateBody struct {
|
||||
DriverOpts map[string]string `json:"DriverOpts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if volumeCreateBody.DriverOpts["type"] == "bind" {
|
||||
return ErrBindMountsForbidden
|
||||
}
|
||||
|
||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !isAdminOrEndpointAdmin {
|
||||
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !securitySettings.AllowBindMountsForRegularUsers {
|
||||
if err := CheckVolumeBodyRestrictions(request); err != nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
|
||||
}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volumeID := request.Header.Get("X-Portainer-VolumeName")
|
||||
|
||||
if volumeID != "" {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const volumeCreationAPIVersion = "1.51"
|
||||
|
||||
type volumeCreationFixtures struct {
|
||||
dockerSrv *httptest.Server
|
||||
ds dataservices.DataStore
|
||||
stdUser portainer.User
|
||||
adminUser portainer.User
|
||||
endpointID portainer.EndpointID
|
||||
}
|
||||
|
||||
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
|
||||
t.Helper()
|
||||
|
||||
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
||||
w.Header().Add("Api-Version", volumeCreationAPIVersion)
|
||||
_, _ = w.Write([]byte{})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(dockerSrv.Close)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
f := &volumeCreationFixtures{
|
||||
dockerSrv: dockerSrv,
|
||||
ds: store,
|
||||
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
|
||||
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
|
||||
endpointID: portainer.EndpointID(1),
|
||||
}
|
||||
|
||||
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
err := tx.User().Create(&f.stdUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.User().Create(&f.adminUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
|
||||
t.Helper()
|
||||
|
||||
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
|
||||
ID: f.endpointID,
|
||||
Name: "test-env",
|
||||
SecuritySettings: settings,
|
||||
})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (f *volumeCreationFixtures) newTransport() *Transport {
|
||||
return &Transport{
|
||||
endpoint: &portainer.Endpoint{ID: f.endpointID},
|
||||
dataStore: f.ds,
|
||||
HTTPTransport: &http.Transport{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}))
|
||||
}
|
||||
|
||||
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newVolumeCreationFixtures(t)
|
||||
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
})
|
||||
|
||||
body := volume.CreateOptions{
|
||||
Name: "evil-volume",
|
||||
Driver: "local",
|
||||
DriverOpts: map[string]string{
|
||||
"type": "bind",
|
||||
"device": "/etc",
|
||||
"o": "bind",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newVolumeCreationFixtures(t)
|
||||
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
})
|
||||
|
||||
body := volume.CreateOptions{
|
||||
Name: "admin-volume",
|
||||
Driver: "local",
|
||||
DriverOpts: map[string]string{
|
||||
"type": "bind",
|
||||
"device": "/etc",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newVolumeCreationFixtures(t)
|
||||
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
})
|
||||
|
||||
body := volume.CreateOptions{
|
||||
Name: "allowed-volume",
|
||||
Driver: "local",
|
||||
DriverOpts: map[string]string{
|
||||
"type": "bind",
|
||||
"device": "/data",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newVolumeCreationFixtures(t)
|
||||
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: false,
|
||||
})
|
||||
|
||||
body := volume.CreateOptions{
|
||||
Name: "normal-volume",
|
||||
Driver: "local",
|
||||
DriverOpts: map[string]string{
|
||||
"type": "tmpfs",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||
require.NotNil(t, resp)
|
||||
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
@@ -80,12 +81,14 @@ func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl,
|
||||
|
||||
if teamAccessesCount > 0 {
|
||||
for _, access := range resourceControl.TeamAccesses {
|
||||
for _, membership := range context.UserMemberships {
|
||||
if membership.TeamID == access.TeamID {
|
||||
return true
|
||||
}
|
||||
if !slices.ContainsFunc(context.UserMemberships, func(m portainer.TeamMembership) bool {
|
||||
return m.TeamID == access.TeamID
|
||||
}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_AdminAlwaysAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
AdministratorsOnly: true,
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: true}
|
||||
|
||||
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_PublicAlwaysAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
Public: true,
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false}
|
||||
|
||||
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_AdministratorsOnlyDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
AdministratorsOnly: true,
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_EmptyAccessesDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_UserAccessMatchingCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
|
||||
|
||||
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_UserAccessNotMatchingCurrentUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_TeamAccessUserMemberOfAllTeams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
TeamAccesses: []portainer.TeamResourceAccess{
|
||||
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
{TeamID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserMemberships: []portainer.TeamMembership{
|
||||
{TeamID: 1},
|
||||
{TeamID: 2},
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAllTeams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
TeamAccesses: []portainer.TeamResourceAccess{
|
||||
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
{TeamID: 3, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserMemberships: []portainer.TeamMembership{
|
||||
{TeamID: 1},
|
||||
{TeamID: 2},
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAnyTeam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
TeamAccesses: []portainer.TeamResourceAccess{
|
||||
{TeamID: 5, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserMemberships: []portainer.TeamMembership{
|
||||
{TeamID: 1},
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_MultipleUserAccessesDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
|
||||
func TestAuthorizedResourceControlUpdate_UserAndTeamAccessCombinationDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc := &portainer.ResourceControl{
|
||||
UserAccesses: []portainer.UserResourceAccess{
|
||||
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
TeamAccesses: []portainer.TeamResourceAccess{
|
||||
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
|
||||
},
|
||||
}
|
||||
ctx := &RestrictedRequestContext{
|
||||
IsAdmin: false,
|
||||
UserID: 1,
|
||||
UserMemberships: []portainer.TeamMembership{
|
||||
{TeamID: 1},
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
|
||||
}
|
||||
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
|
||||
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
|
||||
func extractBearerToken(r *http.Request) (string, bool) {
|
||||
// Token might be set via the "token" query parameter.
|
||||
// For example, in websocket requests
|
||||
// For these cases, hide the token from the query
|
||||
query := r.URL.Query()
|
||||
token := query.Get("token")
|
||||
if token != "" {
|
||||
query.Del("token")
|
||||
r.URL.RawQuery = query.Encode()
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
tokens, ok := r.Header[jwtTokenHeader]
|
||||
if !ok || len(tokens) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token = tokens[0]
|
||||
token := tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
return token, true
|
||||
|
||||
@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
|
||||
}
|
||||
}
|
||||
|
||||
return service.UpdateUsersAuthorizationsTx(tx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
|
||||
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserAuthorizations will update the authorizations for the provided userid
|
||||
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
err := service.updateUserAuthorizations(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
|
||||
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
|
||||
//
|
||||
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
|
||||
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
|
||||
func (service *Service) UpdateUsersAuthorizations() error {
|
||||
return service.UpdateUsersAuthorizationsTx(service.dataStore)
|
||||
}
|
||||
|
||||
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
|
||||
users, err := tx.User().ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
err := service.updateUserAuthorizations(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointAuthorizations, err := service.getAuthorizations(tx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.EndpointAuthorizations = endpointAuthorizations
|
||||
|
||||
return tx.User().Update(userID, user)
|
||||
}
|
||||
|
||||
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
|
||||
endpointAuthorizations := portainer.EndpointAuthorizations{}
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointGroups, err := tx.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
roles, err := tx.Role().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
|
||||
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
|
||||
endpointAuthorizations := make(portainer.EndpointAuthorizations)
|
||||
|
||||
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
|
||||
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
|
||||
for _, endpointGroup := range endpointGroups {
|
||||
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
|
||||
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return endpointAuthorizations
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := endpoint.UserAccessPolicies[user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
|
||||
var associatedRoles []portainer.Role
|
||||
|
||||
for _, id := range roleIdentifiers {
|
||||
for _, role := range roles {
|
||||
if role.ID == id {
|
||||
associatedRoles = append(associatedRoles, role)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizations portainer.Authorizations
|
||||
highestPriority := 0
|
||||
for _, role := range associatedRoles {
|
||||
if role.Priority > highestPriority {
|
||||
highestPriority = role.Priority
|
||||
authorizations = role.Authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return authorizations
|
||||
}
|
||||
|
||||
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package registryutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -14,9 +16,12 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
|
||||
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
|
||||
}
|
||||
|
||||
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||
func fetchRegToken(registry *portainer.Registry) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
|
||||
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
|
||||
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
|
||||
registry.AccessToken = *accessToken
|
||||
registry.AccessTokenExpiry = expiryAt.Unix()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||
if err := fetchRegToken(registry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Registry().Update(registry.ID, registry)
|
||||
}
|
||||
|
||||
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
|
||||
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
|
||||
ParseAuthorizationToken(registry.AccessToken)
|
||||
// ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
|
||||
// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
|
||||
func ValidateRegistriesECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) error {
|
||||
for i := range registries {
|
||||
reg := ®istries[i]
|
||||
if reg.Type != portainer.EcrRegistry {
|
||||
continue
|
||||
}
|
||||
if isRegTokenValid(reg) {
|
||||
continue
|
||||
}
|
||||
if err := doGetRegToken(tx, reg); err != nil {
|
||||
return fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", reg.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
|
||||
password = registry.Password
|
||||
|
||||
if registry.Type == portainer.EcrRegistry {
|
||||
username, password, err = parseRegToken(registry)
|
||||
// Fallback token refresh in case the upstream caller did not pre-validate the token.
|
||||
if !isRegTokenValid(registry) {
|
||||
if err := fetchRegToken(registry); err != nil {
|
||||
return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
|
||||
ParseAuthorizationToken(registry.AccessToken)
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package registryutils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry {
|
||||
return portainer.Registry{
|
||||
ID: id,
|
||||
Type: portainer.EcrRegistry,
|
||||
Name: "test-ecr",
|
||||
Username: "AKIAIOSFODNN7EXAMPLE",
|
||||
Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||
AccessToken: accessToken,
|
||||
AccessTokenExpiry: expiry,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistriesECRTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("skips non-ECR registries without error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
registries := []portainer.Registry{
|
||||
{ID: 1, Type: portainer.DockerHubRegistry, Name: "dockerhub"},
|
||||
{ID: 2, Type: portainer.CustomRegistry, Name: "custom"},
|
||||
}
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return registryutils.ValidateRegistriesECRTokens(tx, registries)
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("skips ECR registries with valid tokens", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("returns nil for empty registry list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{})
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("returns error for ECR registry with missing token", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
reg := newECRRegistry(1, "", 0)
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.Registry().Create(®)
|
||||
}))
|
||||
|
||||
var validateErr error
|
||||
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
|
||||
return nil
|
||||
})
|
||||
require.Error(t, validateErr)
|
||||
require.Contains(t, validateErr.Error(), "test-ecr")
|
||||
})
|
||||
|
||||
t.Run("stops on first invalid ECR registry and includes its name in error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
validECR := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
|
||||
invalidECR := newECRRegistry(2, "", 0)
|
||||
invalidECR.Name = "invalid-ecr"
|
||||
nonECR := portainer.Registry{ID: 3, Type: portainer.DockerHubRegistry}
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return tx.Registry().Create(&invalidECR)
|
||||
}))
|
||||
|
||||
var validateErr error
|
||||
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{validECR, invalidECR, nonECR})
|
||||
return nil
|
||||
})
|
||||
require.Error(t, validateErr)
|
||||
require.Contains(t, validateErr.Error(), "invalid-ecr")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRegEffectiveCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := &portainer.Registry{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(reg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user", username)
|
||||
require.Equal(t, "pass", password)
|
||||
})
|
||||
|
||||
t.Run("parses ECR access token when token is valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix())
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(®)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "AWS", username)
|
||||
require.Equal(t, "ecr-password", password)
|
||||
})
|
||||
|
||||
t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newECRRegistry(1, "", 0)
|
||||
_, _, err := registryutils.GetRegEffectiveCredential(®)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "test-ecr")
|
||||
})
|
||||
}
|
||||
@@ -147,6 +147,27 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
}
|
||||
}
|
||||
|
||||
type stubSSLSettingsService struct {
|
||||
settings *portainer.SSLSettings
|
||||
}
|
||||
|
||||
func (s *stubSSLSettingsService) BucketName() string { return "ssl" }
|
||||
|
||||
func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) {
|
||||
return s.settings, nil
|
||||
}
|
||||
|
||||
func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||
s.settings = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
d.sslSettings = &stubSSLSettingsService{settings: settings}
|
||||
}
|
||||
}
|
||||
|
||||
type stubUserService struct {
|
||||
dataservices.UserService
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, err
|
||||
|
||||
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
|
||||
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
|
||||
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
|
||||
jobs, err := kcl.getCronJobExecutions(cronJob.Name, cronJob.Namespace, jobsList)
|
||||
if err != nil {
|
||||
return models.K8sCronJob{}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
@@ -64,3 +67,62 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
|
||||
t.Logf("Deleted Cron Jobs")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetCronJobExecutionsNamespaceFilter verifies that getCronJobExecutions only returns
|
||||
// executions belonging to the CronJob's own namespace, even when same-named CronJobs
|
||||
// exist across multiple namespaces.
|
||||
func TestGetCronJobExecutionsNamespaceFilter(t *testing.T) {
|
||||
backoffLimit := int32(3)
|
||||
completions := int32(1)
|
||||
|
||||
makeJob := func(name, namespace, cronJobName string) batchv1.Job {
|
||||
return batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
OwnerReferences: []metav1.OwnerReference{
|
||||
{Kind: "CronJob", Name: cronJobName},
|
||||
},
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
BackoffLimit: &backoffLimit,
|
||||
Completions: &completions,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{{Name: "worker", Image: "busybox"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate the cross-namespace job list returned when fetchCronJobs is called with namespace=""
|
||||
allJobs := &batchv1.JobList{
|
||||
Items: []batchv1.Job{
|
||||
makeJob("backup-prod-28001440", "ns-prod", "backup"),
|
||||
makeJob("backup-test-28001441", "ns-test", "backup"),
|
||||
},
|
||||
}
|
||||
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "test",
|
||||
isKubeAdmin: true,
|
||||
}
|
||||
|
||||
t.Run("returns only executions from the matching namespace", func(t *testing.T) {
|
||||
result, err := kcl.getCronJobExecutions("backup", "ns-prod", allJobs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "ns-prod", result[0].Namespace)
|
||||
assert.Equal(t, "backup-prod-28001440", result[0].Name)
|
||||
})
|
||||
|
||||
t.Run("returns only executions from the other matching namespace", func(t *testing.T) {
|
||||
result, err := kcl.getCronJobExecutions("backup", "ns-test", allJobs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "ns-test", result[0].Namespace)
|
||||
assert.Equal(t, "backup-test-28001441", result[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
+26
-10
@@ -3,8 +3,10 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
||||
TTY: true,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
streamOpts := remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
// Try WebSocket executor first, fall back to SPDY if it fails
|
||||
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
|
||||
config,
|
||||
"GET", // WebSocket uses GET for the upgrade request
|
||||
req.URL().String(),
|
||||
channelProtocolList...,
|
||||
)
|
||||
if err != nil {
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
if err == nil {
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("context", "StartExecProcess").
|
||||
Msg("WebSocket exec failed, falling back to SPDY")
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
})
|
||||
// Fall back to SPDY executor
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err != nil {
|
||||
var exitError utilexec.ExitError
|
||||
if !errors.As(err, &exitError) {
|
||||
errChan <- errors.New("unable to start exec process")
|
||||
errChan <- fmt.Errorf("unable to start exec process: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,11 +168,15 @@ func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
|
||||
|
||||
// getCronJobExecutions returns the jobs for a given cronjob
|
||||
// it returns the jobs for the cronjob
|
||||
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
||||
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, cronJobNamespace string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
||||
maxItems := 5
|
||||
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs.Items {
|
||||
if job.Namespace != cronJobNamespace {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, owner := range job.OwnerReferences {
|
||||
if owner.Kind == "CronJob" && owner.Name == cronJobName {
|
||||
results = append(results, kcl.parseJob(job))
|
||||
|
||||
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
|
||||
name: portainer-ctx
|
||||
current-context: portainer-ctx
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
|
||||
+5
-5
@@ -73,7 +73,7 @@ func (Service) AuthenticateUser(username, password string, settings *portainer.L
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
@@ -108,7 +108,7 @@ func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
@@ -133,7 +133,7 @@ func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
@@ -182,7 +182,7 @@ func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPU
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
@@ -309,7 +309,7 @@ func (Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer func() { _ = connection.Close() }()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestCreateConnectionForURL(t *testing.T) {
|
||||
conn, err := createConnectionForURL(settings.URL, settings)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
conn.Close()
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
// TLS
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestCreateConnectionForURL(t *testing.T) {
|
||||
conn, err = createConnectionForURL(settings.URL, settings)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
conn.Close()
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
// Invalid TLS
|
||||
|
||||
|
||||
+11
-14
@@ -30,8 +30,11 @@ func NewService() Service {
|
||||
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
|
||||
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
|
||||
// from the resource server and matching it with the user identifier setting.
|
||||
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := GetOAuthToken(code, configuration)
|
||||
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := GetOAuthToken(ctx, code, configuration)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving oauth token")
|
||||
|
||||
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
log.Error().Err(err).Msg("failed parsing id_token")
|
||||
}
|
||||
|
||||
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
|
||||
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving resource")
|
||||
|
||||
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
|
||||
|
||||
config := buildConfig(configuration)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return config.Exchange(ctx, unescapedCode)
|
||||
}
|
||||
|
||||
@@ -87,9 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{
|
||||
SkipClaimsValidation: true,
|
||||
}
|
||||
jwtParser := jwt.Parser{SkipClaimsValidation: true}
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
|
||||
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
|
||||
|
||||
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
|
||||
code := ""
|
||||
if _, err := GetOAuthToken(code, config); err == nil {
|
||||
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
|
||||
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
|
||||
code := validCode
|
||||
token, err := GetOAuthToken(code, config)
|
||||
token, err := GetOAuthToken(t.Context(), code, config)
|
||||
|
||||
if token == nil || err != nil {
|
||||
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
|
||||
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := authService.Authenticate(code, config); err == nil {
|
||||
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
|
||||
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
|
||||
}
|
||||
})
|
||||
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, config)
|
||||
defer srv.Close()
|
||||
|
||||
username, err := authService.Authenticate(code, config)
|
||||
username, err := authService.Authenticate(t.Context(), code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -20,38 +21,34 @@ type PendingActionsService struct {
|
||||
|
||||
var handlers = make(map[string]portainer.PendingActionHandler)
|
||||
|
||||
func NewService(
|
||||
dataStore dataservices.DataStore,
|
||||
kubeFactory *kubecli.ClientFactory,
|
||||
) *PendingActionsService {
|
||||
return &PendingActionsService{
|
||||
dataStore: dataStore,
|
||||
kubeFactory: kubeFactory,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
|
||||
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
|
||||
handlers[name] = handler
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
|
||||
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
|
||||
// Check if this pendingAction already exists
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if len(pendingActions) > 0 {
|
||||
// Same endpoint, same action and data, don't create a repeat
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
|
||||
reflect.DeepEqual(dba.ActionData, action.ActionData) {
|
||||
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
|
||||
return nil
|
||||
}
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return service.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
|
||||
@@ -65,7 +62,8 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
|
||||
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,48 +84,55 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
// creating a kube client and performing a simple operation
|
||||
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("failed to create Kubernetes client for environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = client.ServerVersion(); err != nil {
|
||||
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("endpoint_name", endpoint.Name).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("environment is not up")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == environmentID
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to read pending actions: %v", err)
|
||||
log.Warn().Err(err).Msg("failed to read pending actions")
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
|
||||
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
|
||||
}
|
||||
|
||||
for i, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == environmentID {
|
||||
if i == 0 {
|
||||
// We have at least 1 pending action for this environment
|
||||
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
|
||||
}
|
||||
for _, pendingAction := range pendingActions {
|
||||
log.Debug().
|
||||
Int("pending_action_id", int(pendingAction.ID)).
|
||||
Str("action", pendingAction.Action).
|
||||
Msg("executing pending action")
|
||||
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to execute pending action")
|
||||
|
||||
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
|
||||
err := service.executePendingAction(pendingAction, endpoint)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to execute pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to delete pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to delete pending action")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +145,8 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
handler, ok := handlers[pendingAction.Action]
|
||||
if !ok {
|
||||
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
|
||||
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+31
-10
@@ -141,6 +141,7 @@ type (
|
||||
LogLevel *string
|
||||
LogMode *string
|
||||
KubectlShellImage *string
|
||||
KubectlShellImageSet bool
|
||||
PullLimitCheckDisabled *bool
|
||||
TrustedOrigins *string
|
||||
}
|
||||
@@ -355,6 +356,20 @@ type (
|
||||
CreatedBy string `example:"admin"`
|
||||
}
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig struct {
|
||||
// Path to a Helm chart folder for Helm git deployments
|
||||
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
|
||||
// Array of paths to Helm values YAML files for Helm git deployments
|
||||
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
|
||||
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
|
||||
Version string `json:"Version,omitempty" example:"1.2.3"`
|
||||
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
|
||||
Atomic bool `json:"Atomic" example:"true"`
|
||||
// Timeout for Helm operations (equivalent to helm --timeout flag)
|
||||
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
|
||||
}
|
||||
|
||||
EdgeStackStatusForEnv struct {
|
||||
EndpointID EndpointID
|
||||
Status []EdgeStackDeploymentStatus
|
||||
@@ -544,11 +559,16 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartStatus struct {
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// EnvironmentID is the endpoint this status belongs to.
|
||||
// Stored so that ReadAll can group statuses by endpoint without parsing keys.
|
||||
EnvironmentID EndpointID `json:"environmentID,omitempty"`
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// Unix timestamp
|
||||
LastAttemptTime int64 `json:"lastAttemptTime"`
|
||||
}
|
||||
|
||||
ImageBundle struct {
|
||||
@@ -557,7 +577,7 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary
|
||||
PolicyChartSummary `mapstructure:",squash"`
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
@@ -584,7 +604,7 @@ type (
|
||||
|
||||
// RestoreSettings contains instructions for restoring environment-level settings
|
||||
RestoreSettings struct {
|
||||
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
|
||||
Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest
|
||||
}
|
||||
|
||||
// RestoreSettingsBundle maps restore type to restoration instructions
|
||||
@@ -1407,6 +1427,7 @@ type (
|
||||
LastActivity time.Time
|
||||
Port int
|
||||
Credentials string
|
||||
HasSnapshot bool
|
||||
}
|
||||
|
||||
// TunnelServerInfo represents information associated to the tunnel server
|
||||
@@ -1815,7 +1836,7 @@ type (
|
||||
|
||||
// OAuthService represents a service used to authenticate users using OAuth
|
||||
OAuthService interface {
|
||||
Authenticate(code string, configuration *OAuthSettings) (string, error)
|
||||
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
|
||||
}
|
||||
|
||||
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
|
||||
@@ -1855,9 +1876,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.37.0"
|
||||
APIVersion = "2.39.3"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
ComposeOptions: options,
|
||||
ForceRecreate: forceRecreate,
|
||||
}); err != nil {
|
||||
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -44,6 +45,10 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
|
||||
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := &ComposeStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,10 @@ func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityCon
|
||||
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := &SwarmStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user