Compare commits

..

16 Commits

Author SHA1 Message Date
portainer-bot[bot] 0b8d0db0be fix(api/workflows): kubernetes UAC (#2507)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2026-04-29 22:48:43 +00:00
Xing c52767fb04 fix(test): isolate registry config in OCI client tests to fix env-dependent failures [C9S-119] (#2497)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-04-30 09:33:09 +12:00
RHCowan 8e39a16172 fix(agent): correct Podman container engine header in sync edge client [BE-12887] (#2498) (#2506) 2026-04-30 09:03:20 +12:00
LP B e964be75db fix(api/workflows): move filterK8SStacks outside of transaction (#2504) 2026-04-29 17:57:02 +02:00
Cara Ryan 6776b01ac8 fix(home):CE group by health down discrepancies between headings and list [C9S-139] (#2484) 2026-04-28 15:42:56 +12:00
bernard-portainer b96031965a fix(environmentlist) use nevironment card in home view [C9S-42] (#2483) 2026-04-28 15:38:15 +12:00
Cara Ryan b2a2e5c222 feat(home): environment home page ui improvements to highlight groups [C9S-23] (#2453)
Signed-off-by: Bernard Setz <bernard.setz@portainer.io>
Co-authored-by: bernard-portainer <bernard.setz@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
Co-authored-by: Dakota Walsh <101994734+dakota-portainer@users.noreply.github.com>
2026-04-28 13:39:48 +12:00
LP B 27285a94ac feat(api/gitops): list and filter kubernetes git workflows (#2465) 2026-04-27 15:19:17 -03:00
Chaim Lev-Ari b3f01973ec fix(ui/sortable-list): remove 1 as page size option [BE-12900] (#2470) 2026-04-27 17:01:08 +03:00
Chaim Lev-Ari 17ffd62480 feat(gitops): show live git validity status in workflow overview [BE-12885] (#2467)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 13:11:52 +03:00
Chaim Lev-Ari 86f6aba362 fix(gitops): align list component with current design [BE-12888] (#2445) 2026-04-26 16:54:51 +03:00
Chaim Lev-Ari 718e11ccd0 fix(kube/stacks): allow empty stack name [BE-12889] (#2446) 2026-04-26 12:14:53 +03:00
Josiah Clumont e68b0e80f1 feat(recommendations): completeness recommendations [C9S-18] (#2262) (#2454) 2026-04-24 14:55:15 +12:00
Ali 9a14f2acb7 feat(docker): add docker builder prune as option [C9S-128] (#2451) 2026-04-24 10:21:32 +12:00
Ali 01ff1486e0 fix(ui): use uuidv4 instead of cryptorandomuuid to support non-secure browsers [c9s-133] (#2433) 2026-04-24 08:41:48 +12:00
andres-portainer b91f77a554 feat(gitops): introduce workflows view [BE-12807] (#2391) (#2428)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-04-22 14:37:04 -03:00
721 changed files with 11378 additions and 26130 deletions
+32 -5
View File
@@ -94,10 +94,7 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.41.1'
- '2.41.0'
- '2.40.0'
- '2.39.2'
- '2.39.1'
- '2.39.0'
- '2.38.1'
@@ -106,7 +103,6 @@ body:
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.8'
- '2.33.7'
- '2.33.6'
- '2.33.5'
@@ -115,7 +111,38 @@ body:
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.0'
- '2.29.2'
- '2.29.1'
- '2.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.9'
- '2.27.8'
- '2.27.7'
- '2.27.6'
- '2.27.5'
- '2.27.4'
- '2.27.3'
- '2.27.2'
- '2.27.1'
- '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
- '2.25.0'
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.5'
- '2.21.4'
validations:
required: true
+8 -10
View File
@@ -1,21 +1,15 @@
// This file has been automatically migrated to valid ESM format by Storybook.
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import path, { dirname } from 'path';
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5';
import { Configuration } from 'webpack';
import postcss from 'postcss';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
const config: StorybookConfig = {
stories: ['../app/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-webpack5-compiler-swc',
'@chromatic-com/storybook',
{
@@ -50,7 +44,6 @@ const config: StorybookConfig = {
],
},
},
'@storybook/addon-docs',
],
webpackFinal: (config) => {
const rules = config?.module?.rules || [];
@@ -103,7 +96,12 @@ const config: StorybookConfig = {
},
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen',
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
},
framework: {
name: '@storybook/react-webpack5',
+8 -39
View File
@@ -1,10 +1,9 @@
import { useEffect } from 'react';
import '../app/assets/css';
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-webpack5';
import { Preview } from '@storybook/react';
initMSW(
{
@@ -27,43 +26,13 @@ const testQueryClient = new QueryClient({
});
const preview: Preview = {
globalTypes: {
theme: {
description: 'Portainer color theme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'highcontrast', title: 'High Contrast', icon: 'eye' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: 'light',
},
decorators: (Story, context) => {
const theme = context.globals.theme;
useEffect(() => {
if (theme === 'light') {
document.documentElement.removeAttribute('theme');
} else {
document.documentElement.setAttribute('theme', theme);
}
}, [theme]);
return (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
);
},
decorators: (Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
loaders: [mswLoader],
parameters: {
options: {
+3 -1
View File
@@ -1,6 +1,8 @@
/* eslint-disable */
/* tslint:disable */
import { v4 as uuidv4 } from 'uuid';
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
@@ -109,7 +111,7 @@ addEventListener('fetch', function (event) {
return;
}
const requestId = crypto.randomUUID();
const requestId = uuidv4();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
+3 -3
View File
@@ -3,7 +3,7 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
@@ -36,8 +36,8 @@ build-storybook: ## Build and serve the storybook files
.PHONY: deps server-deps client-deps tidy
deps: server-deps client-deps ## Download all client and server build dependancies
## This is empty because the pipeline requires it but ce has no server deps
server-deps: init-dist ## Download dependant server binaries
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
pnpm install
@@ -108,7 +108,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
go mod download
go mod download -x
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
-118
View File
@@ -1,118 +0,0 @@
import {
Children,
useState,
useEffect,
useRef,
useContext,
createContext,
ReactNode,
} from 'react';
type MenuCtxType = {
isOpen: boolean;
setOpen: (v: boolean) => void;
menuRef: React.RefObject<HTMLDivElement>;
label: string;
setLabel: (v: string) => void;
};
const MenuCtx = createContext<MenuCtxType | null>(null);
export function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const [label, setLabel] = useState('');
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleDocDown(e: MouseEvent) {
const target = e.target as Node | null;
if (
isOpen &&
menuRef.current &&
target &&
!menuRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleDocDown);
return () => document.removeEventListener('mousedown', handleDocDown);
}, [isOpen]);
return (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
export function MenuButton({
children,
onClick: externalOnClick,
...props
}: {
children?: ReactNode;
onClick?: () => void;
[key: string]: unknown;
}) {
const ctx = useContext(MenuCtx);
useEffect(() => {
const firstText = Children.toArray(children).find(
(c) => typeof c === 'string'
);
if (firstText) ctx?.setLabel(firstText as string);
});
function handleClick() {
externalOnClick?.();
ctx?.setOpen(!ctx.isOpen);
}
return (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
export function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" aria-label={ctx.label || undefined} className={className}>
{children}
</div>
);
}
export function MenuItem({
children,
onSelect,
className,
}: {
children?: ReactNode;
onSelect?: () => void;
className?: string;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
onSelect?.();
ctx?.setOpen(false);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
package agent
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
func tlsServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
return srv
}
func TestGetAgentVersionAndPlatform_Success(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
platform, version, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.NoError(t, err)
require.Equal(t, portainer.AgentPlatformDocker, platform)
require.Equal(t, "2.19.0", version)
}
func TestGetAgentVersionAndPlatform_NonOKStatus(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.Error(t, err)
}
func TestGetAgentVersionAndPlatform_MissingVersionHeader(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.Error(t, err)
}
func TestGetAgentVersionAndPlatform_MissingPlatformHeader(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.Error(t, err)
}
func TestGetAgentVersionAndPlatform_InvalidPlatformZero(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, "0")
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.Error(t, err)
}
func TestGetAgentVersionAndPlatform_NonNumericPlatform(t *testing.T) {
t.Parallel()
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, "docker")
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.Error(t, err)
}
func TestGetAgentVersionAndPlatform_PingPathAppended(t *testing.T) {
t.Parallel()
var gotPath string
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformKubernetes)))
w.WriteHeader(http.StatusNoContent)
})
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
require.NoError(t, err)
require.Equal(t, "/ping", gotPath)
}
+64
View File
@@ -0,0 +1,64 @@
Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
Examples are available at https://documentation.portainer.io/api/api-examples/
You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
# Authentication
Most of the API environments(endpoints) require to be authenticated as well as some level of authorization to be used.
Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
with the **Bearer** authentication mechanism.
Example:
```
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
```
# Security
Each API environment(endpoint) has an associated access policy, it is documented in the description of each environment(endpoint).
Different access policies are available:
- Public access
- Authenticated access
- Restricted access
- Administrator access
### Public access
No authentication is required to access the environments(endpoints) with this access policy.
### Authenticated access
Authentication is required to access the environments(endpoints) with this access policy.
### Restricted access
Authentication is required to access the environments(endpoints) with this access policy.
Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
### Administrator access
Authentication as well as an administrator role are required to access the environments(endpoints) with this access policy.
# Execute Docker requests
Portainer **DO NOT** expose specific environments(endpoints) to manage your Docker resources (create a container, remove a volume, etc...).
Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
To do so, you can use the `/endpoints/{id}/docker` Portainer API environment(endpoint) (which is not documented below due to Swagger limitations). This environment(endpoint) has a restricted access policy so you still need to be authenticated to be able to query this environment(endpoint). Any query on this environment(endpoint) will be proxied to the Docker API of the associated environment(endpoint) (requests and responses objects are the same as documented in the Docker API).
# Private Registry
Using private registry, you will need to pass a based64 encoded JSON string ‘{"registryId":\<registryID value\>}’ inside the Request Header. The parameter name is "X-Registry-Auth".
\<registryID value\> - The registry ID where the repository was created.
Example:
```
eyJyZWdpc3RyeUlkIjoxfQ==
```
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/).
-61
View File
@@ -1,61 +0,0 @@
The Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI, and anything you can do in the UI can also be done via the HTTP API.
API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/)
You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
# Authentication
Most of the API endpoints require authentication, as well as some level of authorization.
Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme.
Example:
```
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
```
# Security
Each API endpoint has an associated access policy, documented in its description.
The following policies are available:
- Public access
- Authenticated access
- Restricted access
- Administrator access
### Public access
No authentication is required.
### Authenticated access
Authentication is required.
### Restricted access
Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered.
### Administrator access
Authentication and an administrator role are both required.
# Execute Docker requests
Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc).
Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API.
To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api).
# Private Registry
When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\<registryId\>}’ where `<registryId>` is the ID of the registry where the repository was created.
Example encoded value:
```
eyJyZWdpc3RyeUlkIjoxfQ==
```
+4 -4
View File
@@ -8,8 +8,8 @@ import (
"time"
)
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
if err != nil {
return
}
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *stri
return
}
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
if err != nil {
return
}
-274
View File
@@ -1,274 +0,0 @@
package backup
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
func TestGetRestoreSourcePath_DBAtRoot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.db"), []byte("db"), 0o600)
require.NoError(t, err)
result, err := getRestoreSourcePath(dir)
require.NoError(t, err)
require.Equal(t, dir, result)
}
func TestGetRestoreSourcePath_EncryptedDBAtRoot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.edb"), []byte("db"), 0o600)
require.NoError(t, err)
result, err := getRestoreSourcePath(dir)
require.NoError(t, err)
require.Equal(t, dir, result)
}
func TestGetRestoreSourcePath_DBInSubdirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
sub := filesystem.JoinPaths(dir, "backup-2024-01-01")
err := os.Mkdir(sub, 0o700)
require.NoError(t, err)
err = os.WriteFile(filesystem.JoinPaths(sub, "portainer.db"), []byte("db"), 0o600)
require.NoError(t, err)
result, err := getRestoreSourcePath(dir)
require.NoError(t, err)
require.Equal(t, sub, result)
}
func TestGetRestoreSourcePath_NoDBFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(filesystem.JoinPaths(dir, "other.file"), []byte("data"), 0o600)
require.NoError(t, err)
result, err := getRestoreSourcePath(dir)
require.NoError(t, err)
require.Equal(t, dir, result)
}
func TestGetRestoreSourcePath_EmptyDir(t *testing.T) {
t.Parallel()
dir := t.TempDir()
result, err := getRestoreSourcePath(dir)
require.NoError(t, err)
require.Equal(t, dir, result)
}
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
plaintext := []byte("sensitive portainer backup data")
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
err := os.WriteFile(srcPath, plaintext, 0o600)
require.NoError(t, err)
encryptedPath, err := encrypt(srcPath, "mysecretpassword")
require.NoError(t, err)
require.Equal(t, srcPath+".encrypted", encryptedPath)
encryptedData, err := os.ReadFile(encryptedPath)
require.NoError(t, err)
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("mysecretpassword"))
require.NoError(t, err)
decrypted, err := io.ReadAll(decryptedReader)
require.NoError(t, err)
require.Equal(t, plaintext, decrypted)
}
func TestEncryptDecrypt_WrongPassword(t *testing.T) {
t.Parallel()
dir := t.TempDir()
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
err := os.WriteFile(srcPath, []byte("data"), 0o600)
require.NoError(t, err)
encryptedPath, err := encrypt(srcPath, "correctpassword")
require.NoError(t, err)
encryptedData, err := os.ReadFile(encryptedPath)
require.NoError(t, err)
_, err = crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("wrongpassword"))
require.Error(t, err)
}
func TestCreateBackupArchive_NoPassword(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, true, false)
storePath := store.GetConnection().GetStorePath()
gate := offlinegate.NewOfflineGate()
archivePath, err := CreateBackupArchive("", gate, store, storePath)
require.NoError(t, err)
f, err := os.Open(archivePath)
require.NoError(t, err)
t.Cleanup(func() {
err := f.Close()
require.NoError(t, err)
})
extractDir := t.TempDir()
err = archive.ExtractTarGz(f, extractDir)
require.NoError(t, err)
dbFound := false
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Name() == "portainer.db" {
dbFound = true
}
return nil
})
require.NoError(t, err)
require.True(t, dbFound, "archive should contain portainer.db")
}
func TestCreateBackupArchive_WithPassword(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, true, false)
storePath := store.GetConnection().GetStorePath()
gate := offlinegate.NewOfflineGate()
archivePath, err := CreateBackupArchive("backup-secret", gate, store, storePath)
require.NoError(t, err)
require.Contains(t, archivePath, ".encrypted")
encryptedData, err := os.ReadFile(archivePath)
require.NoError(t, err)
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("backup-secret"))
require.NoError(t, err)
extractDir := t.TempDir()
err = archive.ExtractTarGz(decryptedReader, extractDir)
require.NoError(t, err)
dbFound := false
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Name() == "portainer.db" {
dbFound = true
}
return nil
})
require.NoError(t, err)
require.True(t, dbFound, "decrypted archive should contain portainer.db")
}
func TestRestoreArchive_NoPassword(t *testing.T) {
t.Parallel()
_, store1 := datastore.MustNewTestStore(t, true, false)
storePath1 := store1.GetConnection().GetStorePath()
gate := offlinegate.NewOfflineGate()
archivePath, err := CreateBackupArchive("", gate, store1, storePath1)
require.NoError(t, err)
archiveData, err := os.ReadFile(archivePath)
require.NoError(t, err)
_, store2 := datastore.MustNewTestStore(t, true, false)
storePath2 := store2.GetConnection().GetStorePath()
ctx, cancel := context.WithCancel(t.Context())
err = RestoreArchive(bytes.NewReader(archiveData), "", storePath2, gate, store2, cancel)
require.NoError(t, err)
require.ErrorIs(t, ctx.Err(), context.Canceled)
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
require.NoError(t, err)
}
func TestRestoreArchive_WithPassword(t *testing.T) {
t.Parallel()
_, store1 := datastore.MustNewTestStore(t, true, false)
storePath1 := store1.GetConnection().GetStorePath()
gate := offlinegate.NewOfflineGate()
archivePath, err := CreateBackupArchive("restore-secret", gate, store1, storePath1)
require.NoError(t, err)
archiveData, err := os.ReadFile(archivePath)
require.NoError(t, err)
_, store2 := datastore.MustNewTestStore(t, true, false)
storePath2 := store2.GetConnection().GetStorePath()
ctx, cancel := context.WithCancel(t.Context())
err = RestoreArchive(bytes.NewReader(archiveData), "restore-secret", storePath2, gate, store2, cancel)
require.NoError(t, err)
require.ErrorIs(t, ctx.Err(), context.Canceled)
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
require.NoError(t, err)
}
func TestRestoreArchive_WrongPassword(t *testing.T) {
t.Parallel()
_, store1 := datastore.MustNewTestStore(t, true, false)
storePath1 := store1.GetConnection().GetStorePath()
gate := offlinegate.NewOfflineGate()
archivePath, err := CreateBackupArchive("correct-password", gate, store1, storePath1)
require.NoError(t, err)
archiveData, err := os.ReadFile(archivePath)
require.NoError(t, err)
_, store2 := datastore.MustNewTestStore(t, true, false)
storePath2 := store2.GetConnection().GetStorePath()
_, cancel := context.WithCancel(t.Context())
err = RestoreArchive(bytes.NewReader(archiveData), "wrong-password", storePath2, gate, store2, cancel)
require.Error(t, err)
}
+11 -48
View File
@@ -243,9 +243,8 @@ func (service *Service) startTunnelVerificationLoop() {
})
}
// 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.
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
func (service *Service) checkTunnels() {
service.mu.RLock()
@@ -256,32 +255,12 @@ 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().
@@ -290,7 +269,13 @@ func (service *Service) checkTunnels() {
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
service.snapshotAndLog(endpointID, tunnelPort)
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.close(endpointID)
return
@@ -299,28 +284,6 @@ 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 {
+4 -165
View File
@@ -1,8 +1,6 @@
package chisel
import (
"context"
"errors"
"net"
"net/http"
"testing"
@@ -19,36 +17,14 @@ func init() {
fips.InitFIPS(false)
}
type mockSnapshotService struct {
snapshotFn func(endpoint *portainer.Endpoint) error
}
func (m *mockSnapshotService) Start(_ context.Context) {}
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,
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := &portainer.Endpoint{
ID: 1,
EdgeID: "test-edge-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
UserTrusted: true,
}
}
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(1)
_, store := datastore.MustNewTestStore(t, false, true)
@@ -81,140 +57,3 @@ func TestPingAgentPanic(t *testing.T) {
require.NoError(t, srv.Shutdown(t.Context()))
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")
}
-16
View File
@@ -9,7 +9,6 @@ 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"
@@ -238,18 +237,3 @@ func encryptCredentials(username, password, key string) (string, error) {
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
}
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
var hasSnapshot bool
_ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
s, err := tx.Snapshot().Read(endpointID)
if err != nil {
return err
}
hasSnapshot = s.Docker != nil || s.Kubernetes != nil
return nil
})
return hasSnapshot
}
+2 -9
View File
@@ -94,20 +94,13 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
flags.KubectlShellImage = kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
-54
View File
@@ -6,7 +6,6 @@ import (
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -27,59 +26,6 @@ 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
+13 -7
View File
@@ -26,6 +26,7 @@ import (
"github.com/portainer/portainer/api/exec"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"github.com/portainer/portainer/api/http"
"github.com/portainer/portainer/api/http/proxy"
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
@@ -52,7 +53,6 @@ import (
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack/compose"
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/portainer/portainer/pkg/validate"
"github.com/google/uuid"
@@ -243,10 +243,6 @@ 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
}
@@ -338,6 +334,7 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
}
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
if flags.FeatureFlags != nil {
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
}
@@ -397,6 +394,9 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
gitService := git.NewService(shutdownCtx)
// Setting insecureSkipVerify to true to preserve the old behaviour.
openAMTService := openamt.NewService(true)
cryptoService := crypto.Service{}
signatureService := initDigitalSignatureService()
@@ -437,11 +437,16 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
reverseTunnelService.ProxyManager = proxyManager
dockerConfigPath := fileService.GetDockerConfigPath()
composeDeployer := compose.NewComposeDeployer()
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager)
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager)
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
@@ -584,6 +589,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
OpenAMTService: openAMTService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
KubeClusterAccessService: kubeClusterAccessService,
+1 -62
View File
@@ -4,9 +4,7 @@ import (
"os"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -15,7 +13,7 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0o600)
err := os.WriteFile(secretPath, []byte(password), 0600)
require.NoError(t, err)
return secretPath
}
@@ -42,65 +40,6 @@ 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) {
t.Parallel()
tests := []struct {
-149
View File
@@ -1,149 +0,0 @@
package concurrent
import (
"context"
"errors"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
)
func TestRun_AllSucceed(t *testing.T) {
t.Parallel()
fn1 := func(ctx context.Context) (any, error) { return "one", nil }
fn2 := func(ctx context.Context) (any, error) { return "two", nil }
fn3 := func(ctx context.Context) (any, error) { return "three", nil }
results, err := Run(t.Context(), 0, fn1, fn2, fn3)
require.NoError(t, err)
require.Len(t, results, 3)
values := make([]string, 0, len(results))
for _, r := range results {
values = append(values, r.Result.(string))
}
require.ElementsMatch(t, []string{"one", "two", "three"}, values)
}
func TestRun_OneError(t *testing.T) {
t.Parallel()
sentinel := errors.New("task failed")
fn1 := func(ctx context.Context) (any, error) { return "ok", nil }
fn2 := func(ctx context.Context) (any, error) { return nil, sentinel }
_, err := Run(t.Context(), 0, fn1, fn2)
require.ErrorIs(t, err, sentinel)
}
func TestRun_NoTasks(t *testing.T) {
t.Parallel()
results, err := Run(t.Context(), 0)
require.NoError(t, err)
require.Empty(t, results)
}
func TestRun_MaxConcurrency(t *testing.T) {
t.Parallel()
const numTasks = 10
var peak atomic.Int32
var active atomic.Int32
task := func(ctx context.Context) (any, error) {
current := active.Add(1)
if current > peak.Load() {
peak.Store(current)
}
time.Sleep(10 * time.Millisecond)
active.Add(-1)
return nil, nil
}
tasks := make([]Func, numTasks)
for i := range tasks {
tasks[i] = task
}
synctest.Test(t, func(t *testing.T) {
results, err := Run(t.Context(), 3, tasks...)
require.NoError(t, err)
require.Len(t, results, numTasks)
require.LessOrEqual(t, peak.Load(), int32(3))
})
}
func TestRun_ZeroConcurrencyUsesAllTasks(t *testing.T) {
t.Parallel()
const numTasks = 5
var peak atomic.Int32
var active atomic.Int32
task := func(ctx context.Context) (any, error) {
current := active.Add(1)
if current > peak.Load() {
peak.Store(current)
}
time.Sleep(20 * time.Millisecond)
active.Add(-1)
return nil, nil
}
tasks := make([]Func, numTasks)
for i := range tasks {
tasks[i] = task
}
synctest.Test(t, func(t *testing.T) {
results, err := Run(t.Context(), 0, tasks...)
require.NoError(t, err)
require.Len(t, results, numTasks)
require.Equal(t, int32(numTasks), peak.Load())
})
}
func TestRun_ContextCancelledBeforeStart(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
called := atomic.Bool{}
fn := func(ctx context.Context) (any, error) {
called.Store(true)
return nil, ctx.Err()
}
_, err := Run(ctx, 1, fn, fn, fn)
require.Error(t, err)
}
func TestRun_ContextPassedToTasks(t *testing.T) {
t.Parallel()
type key struct{}
ctx := context.WithValue(t.Context(), key{}, "testvalue")
fn := func(ctx context.Context) (any, error) {
return ctx.Value(key{}), nil
}
results, err := Run(ctx, 0, fn)
require.NoError(t, err)
require.Equal(t, "testvalue", results[0].Result)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
-1
View File
@@ -59,7 +59,6 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
@@ -607,7 +607,6 @@
"EnableEdgeComputeFeatures": false,
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"ForceSecureCookies": false,
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
@@ -616,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.42.0",
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -661,7 +660,18 @@
"SnapshotInterval": "5m",
"TemplatesURL": "",
"TrustOnFirstConnect": false,
"UserSessionTimeout": "8h"
"UserSessionTimeout": "8h",
"openAMTConfiguration": {
"certFileContent": "",
"certFileName": "",
"certFilePassword": "",
"domainName": "",
"enabled": false,
"mpsPassword": "",
"mpsServer": "",
"mpsToken": "",
"mpsUser": ""
}
},
"snapshots": [
{
@@ -937,7 +947,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.42.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -75
View File
@@ -1,79 +1,5 @@
package exec
import (
"fmt"
"regexp"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/docker/cli/cli/config/types"
"github.com/rs/zerolog/log"
)
import "regexp"
var stackNameNormalizeRegex = regexp.MustCompile("[^-_a-z0-9]+")
func normalizeStackName(name string) string {
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
// fetchEndpointProxy returns the Docker host URL for the given endpoint.
// For remote endpoints it creates a local proxy that handles TLS termination and
// Portainer agent header injection; for local unix/npipe sockets no proxy is needed.
func fetchEndpointProxy(proxyManager *proxy.Manager, endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := proxyManager.CreateAgentProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
}
// 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 {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}
+99 -37
View File
@@ -6,25 +6,35 @@ import (
"io"
"os"
"path"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) *ComposeStackManager {
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
dataStore: dataStore,
}
}
@@ -35,9 +45,9 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeUpOptions) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return errors.Wrap(err, "failed to fetch environment proxy")
}
if proxy != nil {
@@ -46,32 +56,30 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
envFilePath, err := createEnvFile(stack)
if err != nil {
return fmt.Errorf("failed to create env file: %w", err)
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, true)
if err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(options.Registries),
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
}); err != nil {
return fmt.Errorf("failed to deploy a stack: %w", err)
}
return nil
})
return errors.Wrap(err, "failed to deploy a stack")
}
// Run runs a one-off command on a service. Wraps `docker-compose run` command
func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return errors.Wrap(err, "failed to fetch environment proxy")
}
if proxy != nil {
@@ -80,78 +88,86 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
envFilePath, err := createEnvFile(stack)
if err != nil {
return fmt.Errorf("failed to create env file: %w", err)
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, true)
if err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{
err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(options.Registries),
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
Remove: options.Remove,
Args: options.Args,
Detached: options.Detached,
}); err != nil {
return fmt.Errorf("failed to deploy a stack: %w", err)
}
return nil
})
return errors.Wrap(err, "failed to deploy a stack")
}
// Down stops and removes containers, networks, images, and volumes
func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return err
} else if proxy != nil {
defer proxy.Close()
}
if err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
Options: libstack.Options{
WorkingDir: "",
Host: url,
},
}); err != nil {
return fmt.Errorf("failed to remove a stack: %w", err)
}
return nil
})
return errors.Wrap(err, "failed to remove a stack")
}
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
// but does not start containers based on those images.
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return err
} else if proxy != nil {
defer proxy.Close()
}
envFilePath, err := createEnvFile(stack)
if err != nil {
return fmt.Errorf("failed to create env file: %w", err)
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, true)
if err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(options.Registries),
}); err != nil {
return fmt.Errorf("failed to pull images of the stack: %w", err)
}
return nil
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
// NormalizeStackName returns a new stack name with unsupported characters replaced
func (manager *ComposeStackManager) NormalizeStackName(name string) string {
return normalizeStackName(name)
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return "", nil, nil
}
proxy, err := manager.proxyManager.CreateAgentProxyServer(endpoint)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
}
// createEnvFile creates a file that would hold both "in-place" and default environment variables.
@@ -162,7 +178,7 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
}
envFilePath := path.Join(stack.ProjectPath, "stack.env")
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return "", err
}
@@ -213,3 +229,49 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
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
}
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}
+1 -1
View File
@@ -48,7 +48,7 @@ func Test_UpAndDown(t *testing.T) {
deployer := compose.NewComposeDeployer()
w := NewComposeStackManager(deployer, nil)
w := NewComposeStackManager(deployer, nil, nil)
if err := w.Up(t.Context(), stack, endpoint, portainer.ComposeUpOptions{}); err != nil {
t.Fatalf("Error calling docker-compose up: %s", err)
-72
View File
@@ -4,7 +4,6 @@ import (
"io"
"os"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -96,74 +95,3 @@ 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)
})
}
+226 -61
View File
@@ -1,93 +1,258 @@
package exec
import (
"bytes"
"context"
"fmt"
"errors"
"os"
"os/exec"
"path"
"runtime"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
// SwarmStackManager represents a service for managing stacks.
type SwarmStackManager struct {
deployer swarm.Deployer
proxyManager *proxy.Manager
binaryPath string
configPath string
signatureService portainer.DigitalSignatureService
fileService portainer.FileService
reverseTunnelService portainer.ReverseTunnelService
dataStore dataservices.DataStore
}
// NewSwarmStackManager creates a new SwarmStackManager.
// NewSwarmStackManager initializes a new SwarmStackManager service.
// It also updates the configuration of the Docker CLI binary.
func NewSwarmStackManager(
deployer swarm.Deployer,
proxyManager *proxy.Manager,
) *SwarmStackManager {
return &SwarmStackManager{
deployer: deployer,
proxyManager: proxyManager,
binaryPath, configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
datastore dataservices.DataStore,
) (*SwarmStackManager, error) {
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
signatureService: signatureService,
fileService: fileService,
reverseTunnelService: reverseTunnelService,
dataStore: datastore,
}
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
return manager, nil
}
// Deploy creates or updates a Docker Swarm stack.
func (manager *SwarmStackManager) Deploy(
ctx context.Context,
stack *portainer.Stack,
prune bool,
pullImage bool,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return err
}
if proxy != nil {
defer proxy.Close()
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
}
}
return nil
}
// Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(ctx context.Context, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "logout")
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
}
// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(ctx context.Context, stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
filePaths := stackutils.GetStackFilePaths(stack, true)
env := make([]string, 0, len(stack.Env))
for _, ev := range stack.Env {
env = append(env, ev.Name+"="+ev.Value)
}
return manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{
Options: swarm.Options{
ProjectName: stack.Name,
Host: url,
Env: env,
WorkingDir: stack.ProjectPath,
Registries: portainerRegistriesToAuthConfigs(registries),
},
RemoveOrphans: prune,
PullImage: pullImage,
})
}
// Remove deletes all resources belonging to a Swarm stack.
func (manager *SwarmStackManager) Remove(
ctx context.Context,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return fmt.Errorf("failed to fetch environment proxy: %w", err)
return err
}
if proxy != nil {
defer proxy.Close()
if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
} else {
args = append(args, "stack", "deploy", "--with-registry-auth")
}
return manager.deployer.Remove(context.TODO(), stack.Name, swarm.RemoveOptions{
Options: swarm.Options{
Host: url,
},
})
if !pullImage {
args = append(args, "--resolve-image=never")
}
args = configureFilePaths(args, filePaths)
args = append(args, stack.Name)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(ctx, command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
if err != nil {
return err
}
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
}
func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
var stdout bytes.Buffer
cmd := exec.CommandContext(ctx, command, args...)
cmd.Stderr = &stderr
cmd.Stdout = &stdout
if workingDir != "" {
cmd.Dir = workingDir
}
if env != nil {
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
}
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = strings.TrimSpace(stdout.String())
}
if errMsg == "" {
errMsg = err.Error()
}
return errors.New(errMsg)
}
return nil
}
func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string, error) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
if runtime.GOOS == "windows" {
command = path.Join(binaryPath, "docker.exe")
}
args := make([]string, 0)
args = append(args, "--config", configPath)
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
args = append(args, "-H", endpointURL)
if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
} else {
args = append(args, "--tlscacert", "")
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
}
}
return command, args, nil
}
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
}
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
if config["HttpHeaders"] == nil {
config["HttpHeaders"] = make(map[string]any)
}
headersObject := config["HttpHeaders"].(map[string]any)
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
headersObject["X-PortainerAgent-Signature"] = signature
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
return manager.fileService.WriteJSONToFile(configFilePath, config)
}
func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]any, error) {
var config map[string]any
raw, err := manager.fileService.GetFileContent(path, "")
if err != nil {
return make(map[string]any), nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}
return config, nil
}
// NormalizeStackName returns a new stack name with unsupported characters replaced.
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
return normalizeStackName(name)
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}
func configureFilePaths(args []string, filePaths []string) []string {
for _, path := range filePaths {
args = append(args, "--compose-file", path)
}
return args
}
+86
View File
@@ -0,0 +1,86 @@
package exec
import (
"context"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFilePaths(t *testing.T) {
t.Parallel()
args := []string{"stack", "deploy", "--with-registry-auth"}
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}
func TestPrepareDockerCommandAndArgs(t *testing.T) {
t.Parallel()
binaryPath := "/test/dist"
configPath := "/test/config"
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
}
endpoint := &portainer.Endpoint{
URL: "tcp://test:9000",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint)
require.NoError(t, err)
expectedCommand := "/test/dist/docker"
expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""}
require.Equal(t, expectedCommand, command)
require.Equal(t, expectedArgs, args)
}
func TestRunCommandAndCaptureStdErr(t *testing.T) {
t.Parallel()
t.Run("should return nil on successful command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "echo", []string{"hello"}, nil, "")
require.NoError(t, err)
})
t.Run("should capture stderr on failure", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stderr error' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr error")
})
t.Run("should fall back to stdout when stderr is empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout error'; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stdout error")
})
t.Run("should fall back to exec error when both are empty", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "exit 1"}, nil, "")
require.Error(t, err)
assert.NotEmpty(t, err.Error())
assert.Contains(t, err.Error(), "exit status 1")
})
t.Run("should prefer stderr over stdout", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout msg'; echo 'stderr msg' >&2; exit 1"}, nil, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stderr msg")
assert.NotContains(t, err.Error(), "stdout msg")
})
t.Run("should return error for non-existent command", func(t *testing.T) {
err := runCommandAndCaptureStdErr(context.Background(), "nonexistent-cmd-12345", nil, nil, "")
require.Error(t, err)
})
}
+12
View File
@@ -46,6 +46,8 @@ const (
BinaryStorePath = "bin"
// EdgeJobStorePath represents the subfolder where schedule files are stored.
EdgeJobStorePath = "edge_jobs"
// DockerConfigPath represents the subfolder where docker configuration is stored.
DockerConfigPath = "docker_config"
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
// registry management extension are stored.
ExtensionRegistryManagementStorePath = "extensions"
@@ -133,6 +135,11 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err
}
err = service.createDirectoryInStore(DockerConfigPath)
if err != nil {
return nil, err
}
return service, nil
}
@@ -141,6 +148,11 @@ func (service *Service) GetBinaryFolder() string {
return JoinPaths(service.fileStorePath, BinaryStorePath)
}
// GetDockerConfigPath returns the full path to the docker config store on the filesystem
func (service *Service) GetDockerConfigPath() string {
return JoinPaths(service.fileStorePath, DockerConfigPath)
}
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)
+2 -30
View File
@@ -2,9 +2,6 @@ package gittypes
import (
"errors"
"net/url"
"path"
"strings"
)
var (
@@ -13,10 +10,6 @@ var (
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
type GitProvider int
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
@@ -34,30 +27,9 @@ type RepoConfig struct {
TLSSkipVerify bool `example:"false"`
}
// RepoName extracts the repository name from a git URL for use as a display name.
// e.g. "https://github.com/org/app-config.git" results in "app-config"
func RepoName(rawURL string) string {
return strings.TrimSuffix(path.Base(rawURL), ".git")
}
// SanitizeURL strips any userinfo (username/password) embedded in rawURL,
// returning a URL safe to store or return to clients.
func SanitizeURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL
}
u.User = nil
return u.String()
}
type GitAuthentication struct {
Username string
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
Username string
Password string
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0
-106
View File
@@ -1,106 +0,0 @@
package workflows
import (
"context"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
)
// FetchWorkflows returns all GitOps workflows visible to the given user.
func FetchWorkflows(
ctx context.Context,
dataStore dataservices.DataStore,
gitService portainer.GitService,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
endpointIDSet set.Set[portainer.EndpointID],
) ([]Workflow, error) {
var entries []portainer.Stack
var endpointMap map[portainer.EndpointID]portainer.Endpoint
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return err
}
endpointMap, err = buildEndpointMap(tx, stacks)
if err != nil {
return err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return err
}
for i := range stacks {
s := stacks[i]
if ep, ok := endpointMap[s.EndpointID]; ok && !EndpointMatchesStackType(ep, s.Type) {
continue
}
entries = append(entries, s)
}
return nil
})
if err != nil {
return nil, err
}
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
entries, err = filterK8SStacks(entries, endpointMap, k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]Workflow, 0, len(entries))
for _, s := range entries {
gitEntries := []GitEntries{
{Name: s.GitConfig.ConfigFilePath, IsFile: true},
}
for _, additionalPath := range s.AdditionalFiles {
gitEntries = append(gitEntries, GitEntries{Name: additionalPath, IsFile: true})
}
source, artifact := computePhases(ctx, gitService, s.GitConfig, gitEntries)
items = append(items, MapStackToWorkflow(s, s.GitConfig, source, artifact))
}
return items, nil
}
func computePhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig, gitEntries []GitEntries) (source, artifact WorkflowPhaseStatus) {
if gitSvc == nil || cfg == nil {
return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown}
}
username, password := gitCredentials(cfg)
return ComputeGitPhases(ctx, cfg.ReferenceName, gitEntries,
func(ctx context.Context) ([]string, error) {
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
},
func(ctx context.Context, exts []string, dirOnly bool) ([]string, error) {
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, dirOnly, false, exts, cfg.TLSSkipVerify)
},
)
}
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
if cfg.Authentication != nil {
return cfg.Authentication.Username, cfg.Authentication.Password
}
return "", ""
}
-289
View File
@@ -1,289 +0,0 @@
package workflows
import (
"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/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/kubernetes/cli"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
user := &portainer.User{
ID: 1,
Username: "standard",
Role: portainer.StandardUserRole,
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
}
require.NoError(t, store.User().Create(user))
sc := &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 1,
}
kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}
dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}
stacks := []portainer.Stack{kubeStack, dockerStack}
var result []portainer.Stack
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
result, txErr = filterDockerStacksByAccess(tx, stacks, sc)
return txErr
})
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "kube-stack", result[0].Name)
}
func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) {
t.Parallel()
sc := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
}
stacks := []portainer.Stack{
{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack},
{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack},
}
result, err := filterDockerStacksByAccess(nil, stacks, sc)
require.NoError(t, err)
require.Len(t, result, 2)
}
func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) {
t.Parallel()
sc := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
}
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
2: {ID: 2, Type: portainer.DockerEnvironment},
}
result, err := buildEndpointAccessMap(nil, sc, endpointMap)
require.NoError(t, err)
require.Len(t, result, 1)
require.True(t, result[1].isKubeAdmin)
require.Empty(t, result[1].nonAdminNamespaces)
}
func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "default",
Labels: map[string]string{
"io.portainer.kubernetes.application.stackid": "1",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
},
}
_, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{})
require.NoError(t, err)
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
}
stacks := []portainer.Stack{
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: true},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "my-app", result[0].Name)
assert.Equal(t, "default", result[0].Namespace)
}
func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
}
stacks := []portainer.Stack{
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: true},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
require.NoError(t, err)
require.Empty(t, result)
}
func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "ns1",
Labels: map[string]string{
"io.portainer.kubernetes.application.stackid": "1",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
},
}
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
require.NoError(t, err)
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
}
stacks := []portainer.Stack{
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "my-app", result[0].Name)
}
func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
ep := &portainer.Endpoint{
ID: 1,
Type: portainer.KubernetesLocalEnvironment,
}
sc := &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 1,
UserMemberships: []portainer.TeamMembership{
{TeamID: 5},
},
}
access, err := resolveKubeAccess(factory, sc, ep)
require.NoError(t, err)
require.False(t, access.isKubeAdmin)
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
}
func TestResolveKubeAccess_NonAdmin(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
ep := &portainer.Endpoint{
ID: 1,
Type: portainer.KubernetesLocalEnvironment,
}
sc := &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 1,
}
access, err := resolveKubeAccess(factory, sc, ep)
require.NoError(t, err)
require.False(t, access.isKubeAdmin)
require.Equal(t, []string{"default"}, access.nonAdminNamespaces)
}
func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) {
t.Parallel()
fakeKubeClient := kfake.NewSimpleClientset()
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app",
Namespace: "ns1",
Labels: map[string]string{
"io.portainer.kubernetes.application.stackid": "1",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}},
},
}
_, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{})
require.NoError(t, err)
kcl := cli.NewTestKubeClient(fakeKubeClient)
factory := cli.NewTestClientFactory(1, kcl)
endpointMap := map[portainer.EndpointID]portainer.Endpoint{
1: {ID: 1, Type: portainer.KubernetesLocalEnvironment},
}
stacks := []portainer.Stack{
{ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack},
}
accessMap := map[portainer.EndpointID]endpointAccess{
1: {isKubeAdmin: false, nonAdminNamespaces: []string{}},
}
result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap)
require.NoError(t, err)
require.Empty(t, result)
}
+14 -55
View File
@@ -11,17 +11,11 @@ import (
type ListRefsFunc func(ctx context.Context) ([]string, error)
// ListFilesFunc lists files in a repository branch filtered by extension.
type ListFilesFunc func(ctx context.Context, exts []string, dirOnly bool) ([]string, error)
// GitEntries represents a git entry which can be either a file or a directory.
type GitEntries struct {
Name string
IsFile bool
}
type ListFilesFunc func(ctx context.Context, exts []string) ([]string, error)
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
// If source fails, artifact is returned as unknown without making a network call.
func ComputeGitPhases(ctx context.Context, referenceName string, configFilePath []GitEntries, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
func ComputeGitPhases(ctx context.Context, referenceName, configFilePath string, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
source = computeSourcePhase(ctx, referenceName, listRefs)
if source.Status == StatusError {
return source, WorkflowPhaseStatus{Status: StatusUnknown}
@@ -43,58 +37,23 @@ func computeSourcePhase(ctx context.Context, referenceName string, listRefs List
return WorkflowPhaseStatus{Status: StatusHealthy}
}
func computeArtifactPhase(ctx context.Context, gitEntries []GitEntries, listFiles ListFilesFunc) WorkflowPhaseStatus {
if len(gitEntries) == 0 {
func computeArtifactPhase(ctx context.Context, configFilePath string, listFiles ListFilesFunc) WorkflowPhaseStatus {
if configFilePath == "" {
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
}
var (
exts []string
fileEntries []string
dirEntries []string
)
for _, gitEntry := range gitEntries {
if gitEntry.IsFile {
ext := path.Ext(gitEntry.Name)
if len(ext) > 0 {
ext = ext[1:]
exts = append(exts, ext)
}
fileEntries = append(fileEntries, gitEntry.Name)
continue
}
dirEntries = append(dirEntries, gitEntry.Name)
ext := path.Ext(configFilePath)
var exts []string
if len(ext) > 0 {
ext = ext[1:]
exts = []string{ext}
}
// Check file entries
if len(fileEntries) > 0 {
files, err := listFiles(ctx, exts, false)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
for _, fileEntry := range fileEntries {
if !slices.Contains(files, fileEntry) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", fileEntry)}
}
}
files, err := listFiles(ctx, exts)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
// Check directory entries
if len(dirEntries) > 0 {
dirs, err := listFiles(ctx, nil, true)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
for _, dirEntry := range dirEntries {
if !slices.Contains(dirs, dirEntry) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("directory %q not found", dirEntry)}
}
}
if !slices.Contains(files, configFilePath) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", configFilePath)}
}
return WorkflowPhaseStatus{Status: StatusHealthy}
}
+15 -15
View File
@@ -14,20 +14,20 @@ func TestComputeGitPhases(t *testing.T) {
okRefs := func(_ context.Context) ([]string, error) {
return []string{"refs/heads/main"}, nil
}
okFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
okFiles := func(_ context.Context, _ []string) ([]string, error) {
return []string{"docker-compose.yml"}, nil
}
errRefs := func(_ context.Context) ([]string, error) {
return nil, errors.New("connection refused")
}
errFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
errFiles := func(_ context.Context, _ []string) ([]string, error) {
return nil, errors.New("connection refused")
}
cases := []struct {
name string
referenceName string
configFilePath []GitEntries
configFilePath string
listRefs ListRefsFunc
listFiles ListFilesFunc
expectedSource Status
@@ -36,7 +36,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "listRefs errors → source error, artifact unknown",
referenceName: "refs/heads/main",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: errRefs,
listFiles: okFiles,
expectedSource: StatusError,
@@ -45,7 +45,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "ref not in list → source error, artifact unknown",
referenceName: "refs/heads/missing",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: func(_ context.Context) ([]string, error) {
return []string{"refs/heads/main"}, nil
},
@@ -56,7 +56,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "empty configFilePath → artifact error",
referenceName: "refs/heads/main",
configFilePath: []GitEntries{},
configFilePath: "",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
@@ -65,7 +65,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "listFiles errors → artifact error",
referenceName: "refs/heads/main",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: errFiles,
expectedSource: StatusHealthy,
@@ -74,9 +74,9 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "file not in list → artifact error",
referenceName: "refs/heads/main",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: func(_ context.Context, _ []string, _ bool) ([]string, error) {
listFiles: func(_ context.Context, _ []string) ([]string, error) {
return []string{"other.yml"}, nil
},
expectedSource: StatusHealthy,
@@ -85,7 +85,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "both healthy",
referenceName: "refs/heads/main",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
@@ -94,7 +94,7 @@ func TestComputeGitPhases(t *testing.T) {
{
name: "empty referenceName → source healthy (default HEAD)",
referenceName: "",
configFilePath: []GitEntries{{Name: "docker-compose.yml", IsFile: true}},
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
@@ -132,9 +132,9 @@ func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) {
ComputeGitPhases(
t.Context(),
"",
[]GitEntries{{Name: tc.configPath, IsFile: true}},
tc.configPath,
func(_ context.Context) ([]string, error) { return nil, nil },
func(_ context.Context, exts []string, dirOnly bool) ([]string, error) {
func(_ context.Context, exts []string) ([]string, error) {
capturedExts = exts
return []string{tc.configPath}, nil
},
@@ -151,12 +151,12 @@ func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) {
listRefs := func(_ context.Context) ([]string, error) {
return nil, errors.New("repo unreachable")
}
listFiles := func(_ context.Context, _ []string, _ bool) ([]string, error) {
listFiles := func(_ context.Context, _ []string) ([]string, error) {
listFilesCalled = true
return nil, nil
}
ComputeGitPhases(t.Context(), "refs/heads/main", []GitEntries{{Name: "docker-compose.yml", IsFile: true}}, listRefs, listFiles)
ComputeGitPhases(t.Context(), "refs/heads/main", "docker-compose.yml", listRefs, listFiles)
assert.False(t, listFilesCalled, "listFiles must not be called when source fails")
}
+3 -17
View File
@@ -3,7 +3,6 @@ package workflows
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/set"
)
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
@@ -20,8 +19,7 @@ func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, sourc
Artifact: artifact,
Target: deriveStackTargetState(s),
},
GitConfig: gitConfig,
AutoUpdate: s.AutoUpdate,
GitConfig: gitConfig,
Target: Target{
EndpointID: s.EndpointID,
Namespace: s.Namespace,
@@ -51,9 +49,8 @@ func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConf
},
GitConfig: gitConfig,
Target: Target{
EdgeGroupIDs: es.EdgeGroups,
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints),
EdgeGroupIDs: es.EdgeGroups,
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
},
CreationDate: es.CreationDate,
LastSyncDate: edgeStackLastSyncDate(statuses),
@@ -115,17 +112,6 @@ func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool {
return false
}
func resolveEdgeGroupEndpoints(groups []portainer.EdgeGroupID, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) []portainer.EndpointID {
seen := set.Set[portainer.EndpointID]{}
for _, gid := range groups {
for _, epID := range groupEndpoints[gid] {
seen.Add(epID)
}
}
return seen.Keys()
}
func edgeStackTargetStatuses(
groups []portainer.EdgeGroupID,
statuses []portainer.EdgeStackStatusForEnv,
+13 -15
View File
@@ -57,11 +57,10 @@ func ParsePlatform(s string) (DeploymentPlatform, error) {
}
type Target struct {
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
Namespace string `json:"namespace,omitempty"`
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
ResolvedEndpointIDs []portainer.EndpointID `json:"resolvedEndpointIds,omitempty"`
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
Namespace string `json:"namespace,omitempty"`
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
}
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
@@ -79,16 +78,15 @@ type WorkflowStatusObject struct {
}
type Workflow struct {
ID int `json:"id"`
Name string `json:"name"`
Type Type `json:"type"`
Platform DeploymentPlatform `json:"platform"`
Status WorkflowStatusObject `json:"status"`
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"`
Target Target `json:"target"`
CreationDate int64 `json:"creationDate"`
LastSyncDate int64 `json:"lastSyncDate"`
ID int `json:"id"`
Name string `json:"name"`
Type Type `json:"type"`
Platform DeploymentPlatform `json:"platform"`
Status WorkflowStatusObject `json:"status"`
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
Target Target `json:"target"`
CreationDate int64 `json:"creationDate"`
LastSyncDate int64 `json:"lastSyncDate"`
}
type StatusSummary struct {
@@ -0,0 +1,60 @@
package openamt
import (
"bytes"
"fmt"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type authenticationResponse struct {
Token string `json:"token"`
}
func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
payload := map[string]string{
"username": configuration.MPSUser,
"password": configuration.MPSPassword,
}
jsonValue, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
response, err := service.httpsClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := response.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return "", readErr
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return "", errorResponse
}
var token authenticationResponse
if err := json.Unmarshal(responseBody, &token); err != nil {
return "", err
}
return token.Token, nil
}
+151
View File
@@ -0,0 +1,151 @@
package openamt
import (
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type CIRAConfig struct {
ConfigName string `json:"configName"`
MPSServerAddress string `json:"mpsServerAddress"`
ServerAddressFormat int `json:"serverAddressFormat"`
CommonName string `json:"commonName"`
MPSPort int `json:"mpsPort"`
Username string `json:"username"`
MPSRootCertificate string `json:"mpsRootCertificate"`
RegeneratePassword bool `json:"regeneratePassword"`
AuthMethod int `json:"authMethod"`
}
func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
ciraConfig, err := service.getCIRAConfig(configuration, configName)
if err != nil {
return nil, err
}
method := http.MethodPost
if ciraConfig != nil {
method = http.MethodPatch
}
ciraConfig, err = service.saveCIRAConfig(method, configuration, configName)
if err != nil {
return nil, err
}
return ciraConfig, nil
}
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result CIRAConfig
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveCIRAConfig(method string, configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs", configuration.MPSServer)
certificate, err := service.getCIRACertificate(configuration)
if err != nil {
return nil, err
}
addressFormat, err := addressFormat(configuration.MPSServer)
if err != nil {
return nil, err
}
config := CIRAConfig{
ConfigName: configName,
MPSServerAddress: configuration.MPSServer,
CommonName: configuration.MPSServer,
ServerAddressFormat: addressFormat,
MPSPort: 4433,
Username: "admin",
MPSRootCertificate: certificate,
RegeneratePassword: false,
AuthMethod: 2,
}
payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result CIRAConfig
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func addressFormat(url string) (int, error) {
ip := net.ParseIP(url)
if ip == nil {
return 201, nil // FQDN
}
if strings.Contains(url, ".") {
return 3, nil // IPV4
}
if strings.Contains(url, ":") {
return 4, nil // IPV6
}
return 0, fmt.Errorf("could not determine server address format for %s", url)
}
func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/api/v1/ciracert", configuration.MPSServer)
req, err := http.NewRequest(http.MethodGet, loginURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+configuration.MPSToken)
response, err := service.httpsClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := response.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code %s", response.Status)
}
certificate, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
block, _ := pem.Decode(certificate)
return base64.StdEncoding.EncodeToString(block.Bytes), nil
}
@@ -0,0 +1,88 @@
package openamt
import (
"fmt"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type Device struct {
GUID string `json:"guid"`
HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
}
type DevicePowerState struct {
State portainer.PowerState `json:"powerstate"`
}
type DeviceEnabledFeatures struct {
Redirection bool `json:"redirection"`
KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
}
func (service *Service) getDevice(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*Device, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/devices/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
if strings.EqualFold(err.Error(), "invalid value") {
return nil, nil
}
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Device
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDevicePowerState(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DevicePowerState, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/state/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DevicePowerState
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDeviceEnabledFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DeviceEnabledFeatures, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DeviceEnabledFeatures
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
@@ -0,0 +1,82 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type (
Domain struct {
DomainName string `json:"profileName"`
DomainSuffix string `json:"domainSuffix"`
ProvisioningCert string `json:"provisioningCert"`
ProvisioningCertPassword string `json:"provisioningCertPassword"`
ProvisioningCertStorageFormat string `json:"provisioningCertStorageFormat"`
}
)
func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
domain, err := service.getDomain(configuration)
if err != nil {
return nil, err
}
method := http.MethodPost
if domain != nil {
method = http.MethodPatch
}
domain, err = service.saveDomain(method, configuration)
if err != nil {
return nil, err
}
return domain, nil
}
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Domain
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveDomain(method string, configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
profile := Domain{
DomainName: configuration.DomainName,
DomainSuffix: configuration.DomainName,
ProvisioningCert: configuration.CertFileContent,
ProvisioningCertPassword: configuration.CertFilePassword,
ProvisioningCertStorageFormat: "string",
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result Domain
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
@@ -0,0 +1,97 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type (
Profile struct {
ProfileName string `json:"profileName"`
Activation string `json:"activation"`
CIRAConfigName *string `json:"ciraConfigName"`
GenerateRandomAMTPassword bool `json:"generateRandomPassword"`
AMTPassword string `json:"amtPassword"`
GenerateRandomMEBxPassword bool `json:"generateRandomMEBxPassword"`
MEBXPassword string `json:"mebxPassword"`
Tags []string `json:"tags"`
DHCPEnabled bool `json:"dhcpEnabled"`
TenantId string `json:"tenantId"`
WIFIConfigs []ProfileWifiConfig `json:"wifiConfigs"`
}
ProfileWifiConfig struct {
Priority int `json:"priority"`
ProfileName string `json:"profileName"`
}
)
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
profile, err := service.getAMTProfile(configuration, profileName)
if err != nil {
return nil, err
}
method := http.MethodPost
if profile != nil {
method = http.MethodPatch
}
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
if err != nil {
return nil, err
}
return profile, nil
}
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Profile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
profile := Profile{
ProfileName: profileName,
Activation: "acmactivate",
GenerateRandomAMTPassword: false,
GenerateRandomMEBxPassword: false,
AMTPassword: configuration.MPSPassword,
MEBXPassword: configuration.MPSPassword,
CIRAConfigName: &ciraConfigName,
Tags: []string{},
DHCPEnabled: true,
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
var result Profile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
@@ -0,0 +1,56 @@
package openamt
import (
"fmt"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
type ActionResponse struct {
Body struct {
ReturnValue int `json:"ReturnValue"`
ReturnValueStr string `json:"ReturnValueStr"`
} `json:"Body"`
}
func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action int) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/action/%s", configuration.MPSServer, deviceGUID)
payload := map[string]int{
"action": action,
}
jsonValue, _ := json.Marshal(payload)
responseBody, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
var response ActionResponse
err = json.Unmarshal(responseBody, &response)
if err != nil {
return err
}
if response.Body.ReturnValue != 0 {
return fmt.Errorf("failed to execute action, error status %v: %s", response.Body.ReturnValue, response.Body.ReturnValueStr)
}
return nil
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
if strings.EqualFold(actionRaw, "power on") {
return powerOnState, nil
} else if strings.EqualFold(actionRaw, "power off") {
return powerOffState, nil
} else if strings.EqualFold(actionRaw, "restart") {
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}
@@ -0,0 +1,27 @@
package openamt
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
)
func (service *Service) enableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
payload := map[string]any{
"enableSOL": features.SOL,
"enableIDER": features.IDER,
"enableKVM": features.KVM,
"redirection": features.Redirection,
"userConsent": features.UserConsent,
}
jsonValue, _ := json.Marshal(payload)
_, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
return err
}
+262
View File
@@ -0,0 +1,262 @@
package openamt
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"golang.org/x/sync/errgroup"
)
const (
DefaultCIRAConfigName = "ciraConfigDefault"
DefaultProfileName = "profileAMTDefault"
httpClientTimeout = 5 * time.Minute
powerOnState portainer.PowerState = 2
powerOffState portainer.PowerState = 8
restartState portainer.PowerState = 5
)
// Service represents a service for managing an OpenAMT server.
type Service struct {
httpsClient *http.Client
}
// NewService initializes a new service.
func NewService(insecureSkipVerify bool) *Service {
return &Service{
httpsClient: &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
},
},
}
}
type openAMTError struct {
ErrorMsg string `json:"message"`
Errors []struct {
ErrorMsg string `json:"msg"`
} `json:"errors"`
}
func parseError(responseBody []byte) error {
var errorResponse openAMTError
err := json.Unmarshal(responseBody, &errorResponse)
if err != nil {
return err
}
if len(errorResponse.Errors) > 0 {
return errors.New(errorResponse.Errors[0].ErrorMsg)
}
if errorResponse.ErrorMsg != "" {
return errors.New(errorResponse.ErrorMsg)
}
return nil
}
func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
if err != nil {
return err
}
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
if err != nil {
return err
}
_, err = service.createOrUpdateDomain(configuration)
return err
}
func (service *Service) executeSaveRequest(method string, url string, token string, payload []byte) ([]byte, error) {
req, err := http.NewRequest(method, url, bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
response, err := service.httpsClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := response.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode > 300 {
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
}
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
}
func (service *Service) executeGetRequest(url string, token string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
response, err := service.httpsClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := response.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode > 300 {
if response.StatusCode == http.StatusNotFound {
return nil, nil
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
}
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
}
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
token, err := service.Authorization(configuration)
if err != nil {
return nil, err
}
configuration.MPSToken = token
var g errgroup.Group
var resultDevice *Device
var resultPowerState *DevicePowerState
var resultEnabledFeatures *DeviceEnabledFeatures
g.Go(func() error {
device, err := service.getDevice(configuration, deviceGUID)
if err != nil {
return err
}
if device == nil {
return fmt.Errorf("device %s not found", deviceGUID)
}
resultDevice = device
return nil
})
g.Go(func() error {
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
if err != nil {
return err
}
resultPowerState = powerState
return nil
})
g.Go(func() error {
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
if err != nil {
return err
}
resultEnabledFeatures = enabledFeatures
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
deviceInformation := &portainer.OpenAMTDeviceInformation{
GUID: resultDevice.GUID,
HostName: resultDevice.HostName,
ConnectionStatus: resultDevice.ConnectionStatus,
}
if resultPowerState != nil {
deviceInformation.PowerState = resultPowerState.State
}
if resultEnabledFeatures != nil {
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
Redirection: resultEnabledFeatures.Redirection,
KVM: resultEnabledFeatures.KVM,
SOL: resultEnabledFeatures.SOL,
IDER: resultEnabledFeatures.IDER,
UserConsent: resultEnabledFeatures.UserConsent,
}
}
return deviceInformation, nil
}
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
parsedAction, err := parseAction(action)
if err != nil {
return err
}
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
return service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
}
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
token, err := service.Authorization(configuration)
if err != nil {
return "", err
}
configuration.MPSToken = token
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
if err != nil {
return "", err
}
return token, nil
}
@@ -0,0 +1,19 @@
package openamt
import (
"net/http"
"testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
service := NewService(true)
require.NotNil(t, service)
require.True(t, service.httpsClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo
}
+125 -1
View File
@@ -1,13 +1,30 @@
package csrf
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
gcsrf "github.com/gorilla/csrf"
"github.com/rs/zerolog/log"
"github.com/urfave/negroni"
)
const csrfSkipHeader = "X-CSRF-Token-Skip"
// SkipCSRFToken signals that the X-CSRF-Token header should not be sent in the response.
// Deprecated: only meaningful when the "legacy-csrf" feature flag is enabled.
func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
// DOCKER_EXTENSION=1 is set in build/docker-extension/docker-compose.yml
isDockerDesktopExtension := false
@@ -15,6 +32,10 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
isDockerDesktopExtension = true
}
if featureflags.IsEnabled("legacy-csrf") {
return withLegacyProtect(handler, trustedOrigins, isDockerDesktopExtension)
}
cop := http.NewCrossOriginProtection()
for _, origin := range trustedOrigins {
if err := cop.AddTrustedOrigin(origin); err != nil {
@@ -37,7 +58,14 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
protected := cop.Handler(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isDockerDesktopExtension {
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
}
if skip {
handler.ServeHTTP(w, r)
return
@@ -46,3 +74,99 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
protected.ServeHTTP(w, r)
}), nil
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacyProtect(handler http.Handler, trustedOrigins []string, isDockerDesktopExtension bool) (http.Handler, error) {
handler = withLegacySendCSRFToken(handler)
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
// gorilla/csrf compares referer.Host against trusted origin entries, so it
// needs bare host[:port] values rather than full scheme://host[:port] origins.
legacyOrigins := make([]string, len(trustedOrigins))
for i, origin := range trustedOrigins {
parsed, err := url.Parse(origin)
if err != nil {
return nil, fmt.Errorf("failed to parse trusted origin %q: %w", origin, err)
}
legacyOrigins[i] = parsed.Host
}
handler = gcsrf.Protect(
token,
gcsrf.Path("/"),
gcsrf.Secure(false),
gcsrf.TrustedOrigins(legacyOrigins),
gcsrf.ErrorHandler(withLegacyErrorHandler(trustedOrigins)),
)(handler)
return withLegacySkipCSRF(handler, isDockerDesktopExtension), nil
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacySendCSRFToken(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := negroni.NewResponseWriter(w)
sw.Before(func(sw negroni.ResponseWriter) {
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
sw.Header().Del(csrfSkipHeader)
return
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
}
})
handler.ServeHTTP(sw, r)
})
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacySkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
}
if skip {
r = gcsrf.UnsafeSkipCheck(r)
}
handler.ServeHTTP(w, r)
})
}
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
func withLegacyErrorHandler(trustedOrigins []string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := gcsrf.FailureReason(r)
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
log.Error().Err(err).
Str("request_url", r.URL.String()).
Str("host", r.Host).
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Str("forwarded", r.Header.Get("Forwarded")).
Str("origin", r.Header.Get("Origin")).
Str("referer", r.Header.Get("Referer")).
Strs("trusted_origins", trustedOrigins).
Msg("Failed to validate Origin or Referer")
}
http.Error(
w,
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
http.StatusForbidden,
)
})
}
+130
View File
@@ -154,6 +154,51 @@ func TestWithProtect_allowsPostFromTrustedOrigin(t *testing.T) {
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_skipsCsrfForApiKey(t *testing.T) {
t.Parallel()
handler, err := WithProtect(okHandler, nil)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("X-API-KEY", "my-api-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_skipsCsrfForBearerToken(t *testing.T) {
t.Parallel()
handler, err := WithProtect(okHandler, nil)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithProtect_forbidsBothApiKeyAndBearerToken(t *testing.T) {
t.Parallel()
handler, err := WithProtect(okHandler, nil)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-API-KEY", "my-api-key")
req.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
}
func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
t.Parallel()
@@ -168,3 +213,88 @@ func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
}
func TestWithLegacyProtect_noError_noOrigins(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_schemeHostOrigin(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_schemeHostPortOrigin(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com:3000"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_noError_multipleOrigins(t *testing.T) {
t.Parallel()
_, err := withLegacyProtect(okHandler, []string{"https://example.com", "http://internal.example.com:8080"}, false)
require.NoError(t, err)
}
func TestWithLegacyProtect_safeMethodsAlwaysAllowed(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
req := httptest.NewRequest(method, "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "method %s should be allowed", method)
}
}
func TestWithLegacyProtect_blocksPostWithoutToken(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
}
func TestWithLegacyProtect_skipsCsrfForApiKey(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-API-KEY", "my-api-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestWithLegacyProtect_skipsCsrfForBearerToken(t *testing.T) {
t.Parallel()
handler, err := withLegacyProtect(okHandler, nil, false)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer some-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
}
+12 -22
View File
@@ -6,9 +6,7 @@ import (
"strings"
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/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -62,12 +60,8 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid request payload", err)
}
var settings *portainer.Settings
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
settings, err = tx.Settings().Settings()
return err
}); err != nil {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
@@ -95,7 +89,7 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
}
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
return handler.authenticateInternal(rw, r, user, payload.Password, settings.ForceSecureCookies)
return handler.authenticateInternal(rw, user, payload.Password)
}
if settings.AuthenticationMethod == portainer.AuthenticationOAuth {
@@ -103,7 +97,7 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
return handler.authenticateLDAP(rw, r, user, payload.Username, payload.Password, &settings.LDAPSettings, settings.ForceSecureCookies)
return handler.authenticateLDAP(rw, user, payload.Username, payload.Password, &settings.LDAPSettings)
}
return httperror.NewError(http.StatusUnprocessableEntity, "Login method is not supported", httperrors.ErrUnauthorized)
@@ -113,17 +107,17 @@ func isUserInitialAdmin(user *portainer.User) bool {
return int(user.ID) == 1
}
func (handler *Handler) authenticateInternal(w http.ResponseWriter, r *http.Request, user *portainer.User, password string, forceSecureCookies bool) *httperror.HandlerError {
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
if err := handler.CryptoService.CompareHashAndData(user.Password, password); err != nil {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
}
forceChangePassword := !handler.passwordStrengthChecker.Check(password)
return handler.writeToken(w, r, user, forceChangePassword, forceSecureCookies)
return handler.writeToken(w, user, forceChangePassword)
}
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, r *http.Request, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings, forceSecureCookies bool) *httperror.HandlerError {
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
if err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings); err != nil {
if errors.Is(err, httperrors.ErrUnauthorized) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
@@ -148,30 +142,26 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, r *http.Request,
log.Warn().Err(err).Msg("unable to automatically sync user teams with ldap")
}
return handler.writeToken(w, r, user, false, forceSecureCookies)
return handler.writeToken(w, user, false)
}
func (handler *Handler) writeToken(w http.ResponseWriter, r *http.Request, user *portainer.User, forceChangePassword bool, forceSecureCookies bool) *httperror.HandlerError {
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User, forceChangePassword bool) *httperror.HandlerError {
tokenData := composeTokenData(user, forceChangePassword)
return handler.persistAndWriteToken(w, r, tokenData, forceSecureCookies)
return handler.persistAndWriteToken(w, tokenData)
}
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, r *http.Request, tokenData *portainer.TokenData, forceSecureCookies bool) *httperror.HandlerError {
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
token, expirationTime, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
return httperror.InternalServerError("Unable to generate JWT token", err)
}
security.AddAuthCookie(w, token, expirationTime, handler.isSecureCookie(r, forceSecureCookies))
security.AddAuthCookie(w, token, expirationTime)
return response.JSON(w, &authenticateResponse{JWT: token})
}
func (handler *Handler) isSecureCookie(r *http.Request, forceSecureCookies bool) bool {
return r.TLS != nil || middlewares.IsHTTPSRequest(r) || forceSecureCookies
}
func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error {
// only sync if there is a group base DN
if len(settings.GroupSearchSettings) == 0 || len(settings.GroupSearchSettings[0].GroupBaseDN) == 0 {
+3 -8
View File
@@ -6,7 +6,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -63,12 +62,8 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return httperror.BadRequest("Invalid request payload", err)
}
var settings *portainer.Settings
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
settings, err = tx.Settings().Settings()
return err
}); err != nil {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
@@ -118,5 +113,5 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
}
return handler.writeToken(w, r, user, false, settings.ForceSecureCookies)
return handler.writeToken(w, user, false)
}
+1 -12
View File
@@ -4,8 +4,6 @@ import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/logoutcontext"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -31,16 +29,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
handler.bouncer.RevokeJWT(tokenData.Token)
}
var settings *portainer.Settings
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
settings, err = tx.Settings().Settings()
return err
}); err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
security.RemoveAuthCookie(w, handler.isSecureCookie(r, settings.ForceSecureCookies))
security.RemoveAuthCookie(w)
return response.Empty(w)
}
-52
View File
@@ -6,8 +6,6 @@ import (
"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/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -36,7 +34,6 @@ func TestLogout(t *testing.T) {
t.Parallel()
h := NewHandler(NewMockBouncer(), nil, nil, nil)
h.KubernetesTokenCacheManager = kubernetes.NewTokenCacheManager()
_, h.DataStore = datastore.MustNewTestStore(t, true, false)
k, err := cli.NewClientFactory(nil, nil, nil, "", "", "")
require.NoError(t, err)
h.KubernetesClientFactory = k
@@ -51,7 +48,6 @@ func TestLogout(t *testing.T) {
func TestLogoutNoPanic(t *testing.T) {
t.Parallel()
h := NewHandler(testhelpers.NewTestRequestBouncer(), nil, nil, nil)
_, h.DataStore = datastore.MustNewTestStore(t, true, false)
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/logout", nil)
@@ -59,51 +55,3 @@ func TestLogoutNoPanic(t *testing.T) {
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
}
func TestLogout_ClearsCookie(t *testing.T) {
tests := []struct {
name string
forceSecureCookies bool
wantSecure bool
}{
{name: "clears cookie without secure flag", forceSecureCookies: false, wantSecure: false},
{name: "clears cookie with secure flag when ForceSecureCookies is set", forceSecureCookies: true, wantSecure: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := NewHandler(NewMockBouncer(), nil, nil, nil)
h.KubernetesTokenCacheManager = kubernetes.NewTokenCacheManager()
_, h.DataStore = datastore.MustNewTestStore(t, true, false)
k, err := cli.NewClientFactory(nil, nil, nil, "", "", "")
require.NoError(t, err)
h.KubernetesClientFactory = k
err = h.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Settings().UpdateSettings(&portainer.Settings{ForceSecureCookies: tc.forceSecureCookies})
})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/logout", nil)
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
cookies := rr.Result().Cookies()
var authCookie *http.Cookie
for _, c := range cookies {
if c.Name == portainer.AuthCookieKey {
authCookie = c
break
}
}
require.NotNil(t, authCookie, "expected auth cookie to be present in response so the browser can clear it")
require.Empty(t, authCookie.Value)
require.Equal(t, -1, authCookie.MaxAge)
require.Equal(t, tc.wantSecure, authCookie.Secure)
})
}
}
@@ -34,6 +34,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
h.PathPrefix("/{id}/agent/kubernetes").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
h.PathPrefix("/{id}/agent/host").Handler(httperror.LoggerHandler(h.proxyRequestsToAgentHostAPI))
return h
}
@@ -1,59 +0,0 @@
package endpointproxy
import (
"errors"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
func (handler *Handler) proxyRequestsToAgentHostAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
var endpoint *portainer.Endpoint
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
endpoint, err = tx.Endpoint().Endpoint(portainer.EndpointID(endpointID))
return err
}); handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
if endpoint.EdgeID == "" {
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}
}
var proxy http.Handler
proxy = handler.ProxyManager.GetEndpointProxy(endpoint)
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to create proxy", err)
}
}
id := strconv.Itoa(endpointID)
http.StripPrefix("/"+id+"/agent", proxy).ServeHTTP(w, r)
return nil
}
@@ -1,199 +0,0 @@
package endpointproxy
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubTunnelService is a minimal ReverseTunnelService that controls TunnelAddr behavior.
type stubTunnelService struct {
tunnelAddr string
tunnelAddrErr error
}
func (s *stubTunnelService) StartTunnelServer(_, _ string, _ portainer.SnapshotService) error {
return nil
}
func (s *stubTunnelService) StopTunnelServer() error { return nil }
func (s *stubTunnelService) GenerateEdgeKey(_, _ string, _ int) string {
return ""
}
func (s *stubTunnelService) Open(_ *portainer.Endpoint) error { return nil }
func (s *stubTunnelService) Config(_ portainer.EndpointID) portainer.TunnelDetails {
return portainer.TunnelDetails{}
}
func (s *stubTunnelService) TunnelAddr(_ *portainer.Endpoint) (string, error) {
return s.tunnelAddr, s.tunnelAddrErr
}
func (s *stubTunnelService) UpdateLastActivity(_ portainer.EndpointID) {}
func (s *stubTunnelService) KeepTunnelAlive(_ portainer.EndpointID, _ context.Context, _ time.Duration) {
}
// denyBouncer wraps the test bouncer but rejects AuthorizedEndpointOperation.
// Used to test the 403 path without setting up a full JWT stack.
type denyBouncer struct {
security.BouncerService
}
func (denyBouncer) AuthorizedEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error {
return errors.New("access denied to environment")
}
// setupProxyHandler builds a Handler backed by a real (empty) test datastore.
// The real datastore is required because proxyRequestsToAgentHostAPI uses ViewTx,
// which must execute its callback to populate the endpoint variable.
func setupProxyHandler(t *testing.T, bouncer security.BouncerService) (*Handler, *datastore.Store) {
t.Helper()
_, store := datastore.MustNewTestStore(t, false, false)
h := NewHandler(bouncer)
h.DataStore = store
h.ProxyManager = proxy.NewManager(nil)
h.ReverseTunnelService = &stubTunnelService{}
return h, store
}
func TestProxyAgentHostAPI_InvalidEndpointID(t *testing.T) {
t.Parallel()
// A non-numeric environment ID in the URL (e.g. caused by a typo or path-traversal attempt)
// must be rejected immediately with 400 Bad Request.
h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/abc/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
assert.Equal(t, http.StatusBadRequest, rw.Code)
}
func TestProxyAgentHostAPI_EndpointNotFound(t *testing.T) {
t.Parallel()
// The environment was deleted from the database while the user still has a
// browser tab open. The server should return 404, not a 500 or panic.
h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/99/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
assert.Equal(t, http.StatusNotFound, rw.Code)
}
func TestProxyAgentHostAPI_PermissionDenied(t *testing.T) {
t.Parallel()
// A standard user without access to this environment must receive 403 Forbidden.
bouncer := denyBouncer{BouncerService: testhelpers.NewTestRequestBouncer()}
h, store := setupProxyHandler(t, bouncer)
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "env-1",
Type: portainer.AgentOnDockerEnvironment,
}))
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/1/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
assert.Equal(t, http.StatusForbidden, rw.Code)
}
func TestProxyAgentHostAPI_EdgeNoEdgeID(t *testing.T) {
t.Parallel()
// An Edge environment that was registered in Portainer but whose agent has never
// connected (EdgeID is empty) cannot be contacted — the server returns 500.
h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 2,
Name: "edge-env-no-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
EdgeID: "",
}))
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/2/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
func TestProxyAgentHostAPI_EdgeTunnelUnavailable(t *testing.T) {
t.Parallel()
// The Edge agent was registered and has an EdgeID but is currently offline
// (tunnel establishment fails). The user receives 500 rather than a hang.
_, store := datastore.MustNewTestStore(t, false, false)
h := NewHandler(testhelpers.NewTestRequestBouncer())
h.DataStore = store
h.ProxyManager = proxy.NewManager(nil)
h.ReverseTunnelService = &stubTunnelService{
tunnelAddrErr: errors.New("no active tunnel for edge agent"),
}
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 3,
Name: "edge-env-offline",
Type: portainer.EdgeAgentOnDockerEnvironment,
EdgeID: "registered-edge-id",
}))
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/3/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
func TestProxyAgentHostAPI_ProxyCreationFails(t *testing.T) {
t.Parallel()
// When a proxy for the environment has not been cached yet and the proxy factory is
// uninitialised (e.g. a misconfigured server), the handler returns 500 rather than panicking.
h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 4,
Name: "env-4",
Type: portainer.AgentOnDockerEnvironment,
URL: "tcp://agent:9001",
}))
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/4/agent/host/docker-storage", nil)
h.ServeHTTP(rw, req)
// proxy.NewManager(nil) without NewProxyFactory → ErrProxyFactoryNotInitialized → 500
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
// Verify the stubTunnelService satisfies the interface at compile time.
var _ portainer.ReverseTunnelService = (*stubTunnelService)(nil)
// Verify denyBouncer satisfies the interface at compile time.
var _ security.BouncerService = denyBouncer{}
@@ -90,10 +90,6 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
payload.TLS = useTLS
if payload.TLS && payload.EndpointCreationType == edgeAgentEnvironment {
return errors.New("TLS is not supported for Edge Agent environments")
}
if payload.TLS {
skipTLSServerVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true)
payload.TLSSkipVerify = skipTLSServerVerification
@@ -116,61 +116,6 @@ func TestCreateEndpointFailure(t *testing.T) {
require.Nil(t, endpoint)
}
func TestValidateEndpointCreatePayload_TLS(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
testCases := []struct {
name string
formValues map[string][]string
expectedError string
}{
{
name: "edge agent env with TLS rejected",
formValues: map[string][]string{
"Name": {"Test Endpoint"},
"EndpointCreationType": {"4"},
"TLS": {"true"},
},
expectedError: "TLS is not supported for Edge Agent environments",
},
{
name: "edge agent env without TLS succeeds",
formValues: map[string][]string{
"Name": {"Test Endpoint"},
"EndpointCreationType": {"4"},
"URL": {"https://portainer.example:9443"},
},
expectedError: "",
},
{
name: "non-edge agent env with TLS allowed",
formValues: map[string][]string{
"Name": {"Test Endpoint"},
"EndpointCreationType": {"2"},
"TLS": {"true"},
"TLSSkipVerify": {"true"},
"TLSSkipClientVerify": {"true"},
},
expectedError: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := http.Request{Form: tc.formValues}
p := &endpointCreatePayload{}
err := p.Validate(&r)
if tc.expectedError == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tc.expectedError)
}
})
}
}
func TestCreateEdgeAgentEndpoint_ContainerEngineMapping(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
+1 -1
View File
@@ -100,7 +100,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
}
filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext)
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc", settings)
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints)
@@ -219,8 +219,7 @@ func isOutdated(endpoint *portainer.Endpoint) bool {
}
if endpoint.Agent.Version == "" {
edgeHasCheckedInWithoutVersion := endpointutils.IsEdgeEndpoint(endpoint) && endpoint.LastCheckInDate > 0
return edgeHasCheckedInWithoutVersion
return true
}
latestVersion := canonicalizeSemver(portainer.APIVersion)
@@ -147,30 +147,6 @@ func TestSummaryCounts(t *testing.T) {
ByHealth: healthCounts{Up: 1, Down: 1, Heartbeat: 1},
},
},
{
name: "edge endpoint with unknown version and no check-in is not outdated",
endpoints: []testEndpoint{
{endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: 0},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 1, Up: 0, Down: 1, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}},
ByPlatformType: platformCounts{Docker: 1},
ByHealth: healthCounts{Down: 1},
},
},
{
name: "edge endpoint with unknown version but prior check-in is outdated",
endpoints: []testEndpoint{
{endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: time.Now().Add(-1 * time.Hour).Unix()},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 1, Up: 0, Down: 1, Outdated: 1, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}},
ByPlatformType: platformCounts{Docker: 1},
ByHealth: healthCounts{Down: 1, Outdated: 1},
},
},
{
name: "no endpoints returns all zeros",
endpoints: []testEndpoint{},
@@ -297,13 +273,11 @@ func TestResolveEndpointStatus(t *testing.T) {
func TestIsOutdated(t *testing.T) {
currentVersion := portainer.APIVersion
tests := []struct {
name string
version string
lastCheckInDate int64
expected bool
name string
version string
expected bool
}{
{name: "empty version with prior check-in is outdated (old agent style)", version: "", lastCheckInDate: time.Now().Unix(), expected: true},
{name: "empty version with no check-in is not outdated (never connected)", version: "", lastCheckInDate: 0, expected: false},
{name: "empty version is outdated", version: "", expected: true},
{name: "old version is outdated", version: "2.0.0", expected: true},
{name: "v-prefixed old version is outdated", version: "v2.0.0", expected: true},
{name: "current version is not outdated", version: currentVersion, expected: false},
@@ -311,12 +285,7 @@ func TestIsOutdated(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Empty-version logic only applies to edge endpoints; use EdgeAgentOnDocker for those cases.
epType := portainer.AgentOnDockerEnvironment
if tt.version == "" {
epType = portainer.EdgeAgentOnDockerEnvironment
}
ep := &portainer.Endpoint{Type: epType, LastCheckInDate: tt.lastCheckInDate}
ep := &portainer.Endpoint{Type: portainer.AgentOnDockerEnvironment}
ep.Agent.Version = tt.version
assert.Equal(t, tt.expected, isOutdated(ep))
})
@@ -94,10 +94,6 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
if payload.TLS != nil && *payload.TLS && endpointutils.IsEdgeEndpoint(endpoint) {
return httperror.BadRequest("TLS is not supported for Edge Agent environments", nil)
}
updateEndpointProxy := shouldReloadTLSConfiguration(endpoint, &payload)
if payload.Name != nil {
@@ -251,8 +247,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
isStandardKubeAgent := !endpointutils.IsLocalEndpoint(endpoint) && endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
if isStandardKubeAgent {
if !endpointutils.IsLocalEndpoint(endpoint) && endpointutils.IsKubernetesEndpoint(endpoint) {
endpoint.TLSConfig.TLS = true
endpoint.TLSConfig.TLSSkipVerify = true
}
@@ -1,67 +0,0 @@
package endpoints
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_endpointPut_TLSRejectedForEdgeEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, true, true)
h := NewHandler(testhelpers.NewTestRequestBouncer())
h.DataStore = store
testCases := []struct {
name string
endpointType portainer.EndpointType
}{
{
name: "edge agent on docker rejects TLS",
endpointType: portainer.EdgeAgentOnDockerEnvironment,
},
{
name: "edge agent on kubernetes rejects TLS",
endpointType: portainer.EdgeAgentOnKubernetesEnvironment,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
endpointID := portainer.EndpointID(store.Endpoint().GetNextIdentifier())
err := store.Endpoint().Create(&portainer.Endpoint{
ID: endpointID,
Type: tc.endpointType,
})
require.NoError(t, err)
payload := &endpointUpdatePayload{TLS: new(true)}
bodyJSON, err := json.Marshal(payload)
require.NoError(t, err)
url := fmt.Sprintf("/endpoints/%d", endpointID)
req := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(bodyJSON))
rctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
req = req.WithContext(rctx)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
}
}
+3 -26
View File
@@ -20,21 +20,7 @@ func stringComp(a, b string) int {
}
}
func healthRank(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
status := resolveEndpointStatus(endpoint, settings)
if status == statusDown {
return 0
}
if isOutdated(endpoint) {
return 1
}
if status == statusHeartbeat {
return 2
}
return 3
}
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool, settings *portainer.Settings) {
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
if sortField == "" {
return
}
@@ -80,14 +66,7 @@ func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroup
less = func(a, b portainer.Endpoint) int {
return int(endpointutils.EndpointPlatformType(&a) - endpointutils.EndpointPlatformType(&b))
}
case sortKeyHealth:
less = func(a, b portainer.Endpoint) int {
return healthRank(&a, settings) - healthRank(&b, settings)
}
case sortKeyId:
less = func(a, b portainer.Endpoint) int {
return int(a.ID - b.ID)
}
}
slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int {
@@ -110,13 +89,11 @@ const (
sortKeyLastCheckInDate sortKey = "LastCheckIn"
sortKeyEdgeID sortKey = "EdgeID"
sortKeyPlatformType sortKey = "PlatformType"
sortKeyHealth sortKey = "Health"
sortKeyId sortKey = "Id"
)
func getSortKey(sortField string) sortKey {
fieldAsSortKey := sortKey(sortField)
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID, sortKeyPlatformType, sortKeyHealth, sortKeyId}, fieldAsSortKey) {
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID, sortKeyPlatformType}, fieldAsSortKey) {
return fieldAsSortKey
}
+2 -59
View File
@@ -2,7 +2,6 @@ package endpoints
import (
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/slicesx"
@@ -165,9 +164,9 @@ func TestSortEndpointsByField(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
sortEnvironmentsByField(environments, environmentGroups, "Name", false, nil) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, "Name", false) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc, nil)
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc)
is.Equal(tt.expected, getEndpointIDs(environments))
})
@@ -180,62 +179,6 @@ func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID {
})
}
func TestSortEndpointsByHealth(t *testing.T) {
t.Parallel()
settings := &portainer.Settings{EdgeAgentCheckinInterval: 30}
// Down: non-edge with Status=2
down := portainer.Endpoint{
ID: 0,
Name: "down-env",
Type: portainer.DockerEnvironment,
Status: portainer.EndpointStatusDown,
}
// Outdated: edge agent with empty version and a prior check-in (old agent style, no version reported)
outdated := portainer.Endpoint{
ID: 1,
Name: "outdated-env",
Type: portainer.EdgeAgentOnDockerEnvironment,
Status: portainer.EndpointStatusUp,
LastCheckInDate: time.Now().Unix(),
// IsEdgeEndpoint + Agent.Version == "" + LastCheckInDate > 0 → isOutdated returns true
}
// Heartbeat: edge that checked in recently with a current agent version (not outdated)
heartbeat := portainer.Endpoint{
ID: 2,
Name: "heartbeat-env",
Type: portainer.EdgeAgentOnDockerEnvironment,
LastCheckInDate: time.Now().Unix(),
}
heartbeat.Agent.Version = portainer.APIVersion
// Up: non-edge with Status=1
up := portainer.Endpoint{
ID: 3,
Name: "up-env",
Type: portainer.DockerEnvironment,
Status: portainer.EndpointStatusUp,
}
t.Run("ascending: Down → Outdated → Heartbeat → Up", func(t *testing.T) {
environments := []portainer.Endpoint{up, heartbeat, outdated, down}
sortEnvironmentsByField(environments, nil, sortKeyHealth, false, settings)
assert.Equal(t,
[]portainer.EndpointID{down.ID, outdated.ID, heartbeat.ID, up.ID},
getEndpointIDs(environments),
)
})
t.Run("descending: Up → Heartbeat → Outdated → Down", func(t *testing.T) {
environments := []portainer.Endpoint{down, outdated, heartbeat, up}
sortEnvironmentsByField(environments, nil, sortKeyHealth, true, settings)
assert.Equal(t,
[]portainer.EndpointID{up.ID, heartbeat.ID, outdated.ID, down.ID},
getEndpointIDs(environments),
)
})
}
func TestGetSortKey(t *testing.T) {
assert.Equal(t, sortKey("Name"), getSortKey("Name"))
assert.Equal(t, sortKey("PlatformType"), getSortKey("PlatformType"))
-4
View File
@@ -11,7 +11,6 @@ import (
"github.com/gorilla/mux"
"github.com/portainer/portainer/api/http/handler/gitops/sources"
"github.com/portainer/portainer/api/http/handler/gitops/workflows"
)
@@ -39,8 +38,5 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory)
authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler)
sourcesHandler := sources.NewHandler(bouncer, dataStore, gitService, k8sFactory)
authenticatedRouter.PathPrefix("/gitops/sources").Handler(sourcesHandler)
return h
}
-112
View File
@@ -1,112 +0,0 @@
package sources
import (
"net/http"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type connectionInfo struct {
ReferenceName string `json:"referenceName"`
ConfigFilePath string `json:"configFilePath"`
TLSSkipVerify bool `json:"tlsSkipVerify"`
Authentication bool `json:"authentication,omitempty"`
}
type autoUpdateInfo struct {
Mechanism string `json:"mechanism,omitempty"`
FetchInterval string `json:"fetchInterval,omitempty"`
}
// SourceDetail extends Source with connection settings and linked workflows.
type SourceDetail struct {
Source
Connection *connectionInfo `json:"connection,omitempty"`
AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []ce.Workflow `json:"workflows"`
}
// @id GitOpsSourceGet
// @summary Get a GitOps source by ID
// @description Returns a single GitOps source with its connection settings and linked workflows.
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path string true "Source ID"
// @success 200 {object} SourceDetail
// @failure 403 "Access denied"
// @failure 404 "Source not found"
// @failure 500 "Server error"
// @router /gitops/sources/{id} [get]
func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid source ID", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
workflows, err := ce.FetchWorkflows(r.Context(), h.dataStore, h.gitService, h.k8sFactory, securityContext, nil)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
byID := workflowsBySourceID(workflows)
wfs, ok := byID[id]
if !ok || len(wfs) == 0 || wfs[0].GitConfig == nil {
return httperror.NotFound("Source not found", nil)
}
url := wfs[0].GitConfig.URL
detail := SourceDetail{
Source: buildSource(id, url, wfs),
Connection: buildConnectionInfo(wfs[0].GitConfig),
AutoUpdate: buildAutoUpdateInfo(wfs[0].AutoUpdate),
Workflows: redactWorkflowCredentials(wfs),
}
return response.JSON(w, detail)
}
func buildConnectionInfo(cfg *gittypes.RepoConfig) *connectionInfo {
if cfg == nil {
return nil
}
return &connectionInfo{
ReferenceName: cfg.ReferenceName,
ConfigFilePath: cfg.ConfigFilePath,
TLSSkipVerify: cfg.TLSSkipVerify,
Authentication: cfg.Authentication != nil,
}
}
func buildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *autoUpdateInfo {
if autoUpdate == nil {
return nil
}
switch {
case autoUpdate.Interval != "":
return &autoUpdateInfo{
Mechanism: "Interval",
FetchInterval: autoUpdate.Interval,
}
case autoUpdate.Webhook != "":
return &autoUpdateInfo{
Mechanism: "Webhook",
}
default:
return nil
}
}
-115
View File
@@ -1,115 +0,0 @@
package sources
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetSource_NotFound(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, "nonexistent-id"))
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestGetSource_ReturnsDetail(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
cfg := &gittypes.RepoConfig{
URL: "https://github.com/org/repo",
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
TLSSkipVerify: true,
}
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "my-stack", GitConfig: cfg}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
detail := decodeSourceDetail(t, rr)
assert.Equal(t, id, detail.ID)
assert.Equal(t, "repo", detail.Name)
assert.Equal(t, 1, detail.UsedBy)
require.NotNil(t, detail.Connection)
assert.Equal(t, "refs/heads/main", detail.Connection.ReferenceName)
assert.Equal(t, "docker-compose.yml", detail.Connection.ConfigFilePath)
assert.True(t, detail.Connection.TLSSkipVerify)
require.Len(t, detail.Workflows, 1)
assert.Equal(t, "my-stack", detail.Workflows[0].Name)
}
func TestGetSource_RedactsCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
cfg := &gittypes.RepoConfig{
URL: "https://github.com/org/secure",
Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"},
}
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "secure-stack", GitConfig: cfg}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
detail := decodeSourceDetail(t, rr)
require.Len(t, detail.Workflows, 1)
require.NotNil(t, detail.Workflows[0].GitConfig)
require.NotNil(t, detail.Workflows[0].GitConfig.Authentication)
assert.Equal(t, "user", detail.Workflows[0].GitConfig.Authentication.Username)
assert.Empty(t, detail.Workflows[0].GitConfig.Authentication.Password)
}
func TestGetSource_AutoUpdate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
cfg := gitCfg("https://github.com/org/polled")
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1,
Name: "polled-stack",
GitConfig: cfg,
AutoUpdate: &portainer.AutoUpdateSettings{Interval: "5m"},
}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
id := sourceID(gitSourceKey(cfg))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildGetReq(t, 1, id))
detail := decodeSourceDetail(t, rr)
require.NotNil(t, detail.AutoUpdate)
assert.Equal(t, "Interval", detail.AutoUpdate.Mechanism)
assert.Equal(t, "5m", detail.AutoUpdate.FetchInterval)
}
@@ -1,46 +0,0 @@
package sources
import (
"net/http"
"time"
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
)
const (
cacheTTL = 30 * time.Second
cacheCleanupInterval = 10 * time.Minute
)
// Handler is the HTTP handler for the GitOps sources API.
type Handler struct {
*mux.Router
dataStore dataservices.DataStore
gitService portainer.GitService
cache *gocache.Cache
k8sFactory *cli.ClientFactory
}
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
gitService: gitService,
cache: gocache.New(cacheTTL, cacheCleanupInterval),
k8sFactory: k8sFactory,
}
adminRouter := h.PathPrefix("/gitops/sources").Subrouter()
adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
adminRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
return h
}
@@ -1,65 +0,0 @@
package sources
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler {
t.Helper()
return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil)
}
func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
func decodeSources(t *testing.T, rr *httptest.ResponseRecorder) []Source {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var items []Source
require.NoError(t, json.NewDecoder(rr.Body).Decode(&items))
return items
}
func decodeSourceDetail(t *testing.T, rr *httptest.ResponseRecorder) SourceDetail {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var item SourceDetail
require.NoError(t, json.NewDecoder(rr.Body).Decode(&item))
return item
}
func gitCfg(url string) *gittypes.RepoConfig {
return &gittypes.RepoConfig{
URL: url,
ConfigFilePath: "docker-compose.yml",
ReferenceName: "refs/heads/main",
}
}
-122
View File
@@ -1,122 +0,0 @@
package sources
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
gocache "github.com/patrickmn/go-cache"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters"
"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"
)
// @id GitOpsSourcesList
// @summary List all GitOps sources
// @description Returns a deduplicated list of git repositories used across all GitOps workflows.
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param search query string false "Search term (matches URL)"
// @param sort query string false "Sort field: name | status | type"
// @param order query string false "Sort order: asc or desc"
// @param start query int false "Pagination start index"
// @param limit query int false "Pagination limit (0 = unlimited)"
// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown"
// @param type query SourceType false "Filter by source type: git | oci | helm"
// @success 200 {array} Source
// @failure 400 "Invalid status parameter"
// @failure 403 "Access denied"
// @failure 500 "Server error"
// @router /gitops/sources [get]
func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
params := filters.ExtractListModifiersQueryParams(r)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
key := cacheKey(securityContext)
sources, err := h.getSources(r.Context(), key, securityContext)
if err != nil {
return httperror.InternalServerError("Unable to retrieve sources", err)
}
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
s, err := ceWorkflows.ParseStatus(status)
if err != nil {
return httperror.BadRequest("Invalid status parameter", err)
}
sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Status == s })
}
if sourceType, _ := request.RetrieveQueryParameter(r, "type", true); sourceType != "" {
t, err := parseSourceType(sourceType)
if err != nil {
return httperror.BadRequest("Invalid type parameter", err)
}
sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Type == t })
}
results := filters.SearchOrderAndPaginate(sources, params, filters.Config[Source]{
SearchAccessors: []filters.SearchAccessor[Source]{
func(s Source) (string, error) { return s.URL, nil },
},
SortBindings: []filters.SortBinding[Source]{
{Key: "name", Fn: func(a, b Source) int { return strings.Compare(a.Name, b.Name) }},
{Key: "status", Fn: func(a, b Source) int { return strings.Compare(string(a.Status), string(b.Status)) }},
{Key: "type", Fn: func(a, b Source) int { return strings.Compare(a.Type, b.Type) }},
},
})
filters.ApplyFilterResultsHeaders(&w, results)
return response.JSON(w, results.Items)
}
func (h *Handler) getSources(ctx context.Context, key string, sc *security.RestrictedRequestContext) ([]Source, error) {
if cached, ok := h.cache.Get(key); ok {
return slices.Clone(cached.([]Source)), nil
}
result, err := h.fetchSources(ctx, sc)
if err != nil {
return nil, err
}
h.cache.Set(key, result, gocache.DefaultExpiration)
return slices.Clone(result), nil
}
func cacheKey(sc *security.RestrictedRequestContext) string {
teamIDs := make([]string, len(sc.UserMemberships))
for i, membership := range sc.UserMemberships {
teamIDs[i] = strconv.Itoa(int(membership.TeamID))
}
slices.Sort(teamIDs)
return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(teamIDs, ",")
}
func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) {
workflows, err := ceWorkflows.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, nil)
if err != nil {
return nil, err
}
byID := workflowsBySourceID(workflows)
sources := make([]Source, 0, len(byID))
for id, wfs := range byID {
sources = append(sources, buildSource(id, wfs[0].GitConfig.URL, wfs))
}
return sources, nil
}
@@ -1,111 +0,0 @@
package sources
import (
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSourcesList_GroupsByURLAndCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "stack-a", GitConfig: gitCfg("https://github.com/org/repo"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, Name: "stack-b", GitConfig: gitCfg("https://github.com/org/repo"),
}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildListReq(t, 1, ""))
sources := decodeSources(t, rr)
require.Len(t, sources, 1)
assert.Equal(t, 2, sources[0].UsedBy)
}
func TestSourcesList_SeparatesCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
cfg1 := gitCfg("https://github.com/org/repo")
cfg1.Authentication = &gittypes.GitAuthentication{Username: "alice", Password: "pass1"}
cfg2 := gitCfg("https://github.com/org/repo")
cfg2.Authentication = &gittypes.GitAuthentication{Username: "bob", Password: "pass2"}
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "stack-a", GitConfig: cfg1}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "stack-b", GitConfig: cfg2}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildListReq(t, 1, ""))
sources := decodeSources(t, rr)
assert.Len(t, sources, 2)
}
func TestSourcesList_StatusFilter(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
// With nil gitService, source git-phase status is always StatusUnknown.
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, GitConfig: gitCfg("https://github.com/org/app"),
}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
t.Run("status=unknown matches sources with unknown status", func(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildListReq(t, 1, "status=unknown"))
sources := decodeSources(t, rr)
assert.Len(t, sources, 1)
})
t.Run("status=healthy excludes sources with unknown status", func(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildListReq(t, 1, "status=healthy"))
sources := decodeSources(t, rr)
assert.Empty(t, sources)
})
}
func TestSourcesList_SearchByURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, GitConfig: gitCfg("https://github.com/org/app"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, GitConfig: gitCfg("https://github.com/org/infra"),
}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildListReq(t, 1, "search=app"))
sources := decodeSources(t, rr)
require.Len(t, sources, 1)
assert.Equal(t, "app", sources[0].Name)
}
@@ -1,58 +0,0 @@
package sources
import (
"net/http"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GitOpsSourcesSummary
// @summary Summarize GitOps source status counts
// @description Returns a count of sources per status.
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @success 200 {object} ce.StatusSummary
// @failure 403 "Access denied"
// @failure 500 "Server error"
// @router /gitops/sources/summary [get]
func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !securityContext.IsAdmin {
return httperror.Forbidden("Access denied", nil)
}
key := cacheKey(securityContext)
sources, err := h.getSources(r.Context(), key, securityContext)
if err != nil {
return httperror.InternalServerError("Unable to retrieve sources", err)
}
summary := ce.StatusSummary{}
for _, s := range sources {
switch s.Status {
case ce.StatusHealthy:
summary.Healthy++
case ce.StatusSyncing:
summary.Syncing++
case ce.StatusError:
summary.Error++
case ce.StatusPaused:
summary.Paused++
default:
summary.Unknown++
}
}
return response.JSON(w, summary)
}
-86
View File
@@ -1,86 +0,0 @@
package sources
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"strings"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
)
// Source represents a unique git repository used as a GitOps source across one or more workflows.
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Status workflows.Status `json:"status"`
Error string `json:"error,omitempty"`
UsedBy int `json:"usedBy"`
Environments int `json:"environments"`
LastSync int64 `json:"lastSync"`
}
type SourceType string
const (
SourceTypeGit SourceType = "git"
SourceTypeHelm SourceType = "helm"
SourceTypeOCI SourceType = "oci"
)
func parseSourceType(s string) (string, error) {
switch SourceType(s) {
case SourceTypeGit, SourceTypeHelm, SourceTypeOCI:
return s, nil
default:
return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s)
}
}
type sourceGroupKey struct {
URL string
Username string
Password string
}
func gitSourceKey(cfg *gittypes.RepoConfig) sourceGroupKey {
key := sourceGroupKey{URL: cfg.URL}
if cfg.Authentication != nil {
key.Username = cfg.Authentication.Username
key.Password = cfg.Authentication.Password
}
return key
}
func sourceID(key sourceGroupKey) string {
h := sha256.Sum256([]byte(key.URL + "\x00" + key.Username + "\x00" + key.Password))
return hex.EncodeToString(h[:8])
}
// repoName extracts the repository name from a URL.
// e.g. "https://github.com/org/app-config.git" → "app-config"
func repoName(rawURL string) string {
base := path.Base(rawURL)
return strings.TrimSuffix(base, ".git")
}
func worstCaseStatus(statuses []workflows.Status) workflows.Status {
priority := map[workflows.Status]int{
workflows.StatusError: 4,
workflows.StatusSyncing: 3,
workflows.StatusPaused: 2,
workflows.StatusHealthy: 1,
workflows.StatusUnknown: 0,
}
worst := workflows.StatusUnknown
for _, s := range statuses {
if priority[s] > priority[worst] {
worst = s
}
}
return worst
}
-67
View File
@@ -1,67 +0,0 @@
package sources
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/set"
)
func workflowsBySourceID(workflows []ce.Workflow) map[string][]ce.Workflow {
byID := make(map[string][]ce.Workflow)
for _, wf := range workflows {
if wf.GitConfig != nil {
id := sourceID(gitSourceKey(wf.GitConfig))
byID[id] = append(byID[id], wf)
}
}
return byID
}
func buildSource(id, url string, wfs []ce.Workflow) Source {
statuses := make([]ce.Status, 0, len(wfs))
var sourceError string
var lastSync int64
endpointIDs := make(set.Set[portainer.EndpointID])
for _, wf := range wfs {
statuses = append(statuses, wf.Status.Source.Status)
if sourceError == "" && wf.Status.Source.Status == ce.StatusError {
sourceError = wf.Status.Source.Error
}
if wf.LastSyncDate > lastSync {
lastSync = wf.LastSyncDate
}
if wf.Target.EndpointID != 0 {
endpointIDs.Add(wf.Target.EndpointID)
}
for _, id := range wf.Target.ResolvedEndpointIDs {
endpointIDs.Add(id)
}
}
return Source{
ID: id,
Name: repoName(url),
Type: "git",
URL: gittypes.SanitizeURL(url),
Status: worstCaseStatus(statuses),
Error: sourceError,
UsedBy: len(wfs),
Environments: len(endpointIDs),
LastSync: lastSync,
}
}
func redactWorkflowCredentials(wfs []ce.Workflow) []ce.Workflow {
redacted := make([]ce.Workflow, len(wfs))
for i, wf := range wfs {
redacted[i] = wf
if wf.GitConfig != nil && wf.GitConfig.Authentication != nil {
cfg := *wf.GitConfig
auth := *wf.GitConfig.Authentication
auth.Password = ""
cfg.Authentication = &auth
redacted[i].GitConfig = &cfg
}
}
return redacted
}
@@ -1,170 +0,0 @@
package sources
import (
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowsBySourceID(t *testing.T) {
t.Parallel()
t.Run("groups workflows with same URL and credentials", func(t *testing.T) {
t.Parallel()
cfg := gitCfg("https://github.com/org/repo")
wfs := []ce.Workflow{{GitConfig: cfg}, {GitConfig: cfg}}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 1)
for _, group := range byID {
assert.Len(t, group, 2)
}
})
t.Run("separates workflows with same URL but different credentials", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo",
Authentication: &gittypes.GitAuthentication{Username: "alice", Password: "pass1"}}},
{GitConfig: &gittypes.RepoConfig{URL: "https://github.com/org/repo",
Authentication: &gittypes.GitAuthentication{Username: "bob", Password: "pass2"}}},
}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 2)
})
t.Run("skips workflows with nil GitConfig", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{}, {GitConfig: gitCfg("https://github.com/org/repo")}}
byID := workflowsBySourceID(wfs)
assert.Len(t, byID, 1)
})
}
func TestBuildSource(t *testing.T) {
t.Parallel()
t.Run("status is the worst across all workflows", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusHealthy}}},
{Status: ce.WorkflowStatusObject{Source: ce.WorkflowPhaseStatus{Status: ce.StatusError, Error: "boom"}}},
}
s := buildSource("id", "https://github.com/org/repo.git", wfs)
assert.Equal(t, ce.StatusError, s.Status)
assert.Equal(t, "boom", s.Error)
})
t.Run("usedBy equals the number of workflows", func(t *testing.T) {
t.Parallel()
wfs := make([]ce.Workflow, 3)
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, 3, s.UsedBy)
})
t.Run("environments deduplicates endpoint IDs", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{Target: ce.Target{EndpointID: portainer.EndpointID(1)}},
{Target: ce.Target{EndpointID: portainer.EndpointID(1)}}, // duplicate
{Target: ce.Target{EndpointID: portainer.EndpointID(2)}},
}
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, 2, s.Environments)
})
t.Run("name is extracted from URL", func(t *testing.T) {
t.Parallel()
s := buildSource("id", "https://github.com/org/my-app.git", []ce.Workflow{{}})
assert.Equal(t, "my-app", s.Name)
})
t.Run("lastSync is the maximum across all workflows", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{
{LastSyncDate: 100},
{LastSyncDate: 300},
{LastSyncDate: 200},
}
s := buildSource("id", "https://github.com/org/repo", wfs)
assert.Equal(t, int64(300), s.LastSync)
})
}
func TestRedactWorkflowCredentials(t *testing.T) {
t.Parallel()
t.Run("clears password and preserves username", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{
Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"},
}}}
got := redactWorkflowCredentials(wfs)
require.NotNil(t, got[0].GitConfig.Authentication)
assert.Equal(t, "user", got[0].GitConfig.Authentication.Username)
assert.Empty(t, got[0].GitConfig.Authentication.Password)
})
t.Run("does not mutate the original slice", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{
Authentication: &gittypes.GitAuthentication{Password: "s3cr3t"},
}}}
_ = redactWorkflowCredentials(wfs)
assert.Equal(t, "s3cr3t", wfs[0].GitConfig.Authentication.Password)
})
t.Run("nil GitConfig is safe", func(t *testing.T) {
t.Parallel()
assert.NotPanics(t, func() { redactWorkflowCredentials([]ce.Workflow{{}}) })
})
t.Run("nil Authentication is safe", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{}}}
assert.NotPanics(t, func() { redactWorkflowCredentials(wfs) })
})
}
func TestBuildAutoUpdateInfo(t *testing.T) {
t.Parallel()
assert.Nil(t, buildAutoUpdateInfo(nil))
assert.Nil(t, buildAutoUpdateInfo(&portainer.AutoUpdateSettings{}))
got := buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"})
require.NotNil(t, got)
assert.Equal(t, "Interval", got.Mechanism)
assert.Equal(t, "5m", got.FetchInterval)
got = buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"})
require.NotNil(t, got)
assert.Equal(t, "Webhook", got.Mechanism)
assert.Empty(t, got.FetchInterval)
}
func TestBuildConnectionInfo(t *testing.T) {
t.Parallel()
assert.Nil(t, buildConnectionInfo(nil))
cfg := &gittypes.RepoConfig{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{Username: "user"},
}
got := buildConnectionInfo(cfg)
require.NotNil(t, got)
assert.Equal(t, "refs/heads/main", got.ReferenceName)
assert.Equal(t, "docker-compose.yml", got.ConfigFilePath)
assert.True(t, got.TLSSkipVerify)
assert.True(t, got.Authentication)
got = buildConnectionInfo(&gittypes.RepoConfig{})
assert.False(t, got.Authentication)
}
@@ -2,12 +2,9 @@ package workflows
import (
"fmt"
"slices"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -18,7 +15,7 @@ import (
"github.com/portainer/portainer/api/stacks/stackutils"
)
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
switch stackType {
case portainer.DockerSwarmStack:
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
@@ -50,6 +47,11 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma
return m, nil
}
type endpointAccess struct {
isKubeAdmin bool
nonAdminNamespaces []string
}
// filterDockerStacksByAccess filters stacks to only those the current user can access.
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
if sc.IsAdmin {
@@ -100,11 +102,6 @@ func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedReq
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
}
type endpointAccess struct {
isKubeAdmin bool
nonAdminNamespaces []string
}
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
@@ -123,56 +120,3 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
return result, nil
}
// lookup only if env is kube and either not edge or (edge + not async)
func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
return endpointutils.IsKubernetesEndpoint(endpoint) &&
(!endpointutils.IsEdgeEndpoint(endpoint) ||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
}
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
return s.Type == portainer.KubernetesStack
})
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
return s.EndpointID
})
for envID, stacks := range groupedByEnvId {
ep, ok := endpointMap[envID]
if !ok || !ShouldPerformEnvLookup(&ep) {
continue
}
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
return nil, err
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
}
for _, s := range stacks {
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
})
if idx == -1 {
continue
}
app := apps[idx]
s.Name = app.Name
s.Namespace = app.ResourcePool
result = append(result, s)
}
}
return result, nil
}
@@ -0,0 +1,32 @@
package workflows
import (
"context"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
wf "github.com/portainer/portainer/api/gitops/workflows"
)
func computeGitPhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact wf.WorkflowPhaseStatus) {
if gitSvc == nil || cfg == nil {
return wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}, wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}
}
username, password := gitCredentials(cfg)
return wf.ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath,
func(ctx context.Context) ([]string, error) {
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
},
func(ctx context.Context, exts []string) ([]string, error) {
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify)
},
)
}
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
if cfg.Authentication != nil {
return cfg.Authentication.Username, cfg.Authentication.Password
}
return "", ""
}
+113 -1
View File
@@ -10,9 +10,13 @@ import (
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
svc "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -137,7 +141,115 @@ func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.Res
}
func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
return svc.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, endpointIDSet)
var entries []portainer.Stack
var endpointMap map[portainer.EndpointID]portainer.Endpoint
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return err
}
endpointMap, err = buildEndpointMap(tx, stacks)
if err != nil {
return err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return err
}
for i := range stacks {
s := stacks[i]
if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) {
continue
}
entries = append(entries, s)
}
return nil
})
if err != nil {
return nil, err
}
accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]svc.Workflow, 0, len(entries))
for _, s := range entries {
source, artifact := computeGitPhases(ctx, h.gitService, s.GitConfig)
items = append(items, svc.MapStackToWorkflow(s, s.GitConfig, source, artifact))
}
return items, nil
}
// lookup only if env is kube and either not edge or (edge + not async)
func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
return endpointutils.IsKubernetesEndpoint(endpoint) &&
(!endpointutils.IsEdgeEndpoint(endpoint) ||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
}
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
return s.Type == portainer.KubernetesStack
})
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
return s.EndpointID
})
for envID, stacks := range groupedByEnvId {
ep, ok := endpointMap[envID]
if !ok || !shouldPerformEnvLookup(&ep) {
continue
}
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
return nil, err
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
}
for _, s := range stacks {
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
})
if idx == -1 {
// if we don't find a matching application (deployment/statefulset/daemonset) in the environment workloads
// this workflow (stack) wouldn't show in the Applications list, so we don't keep it
continue
}
app := apps[idx]
s.Name = app.Name
s.Namespace = app.ResourcePool
result = append(result, s)
}
}
return result, nil
}
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
+10 -37
View File
@@ -18,6 +18,7 @@ import (
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/gitops"
"github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
"github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
@@ -64,6 +65,7 @@ type Handler struct {
RoleHandler *roles.Handler
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler
StackHandler *stacks.Handler
StorybookHandler *storybook.Handler
SystemHandler *system.Handler
@@ -79,9 +81,8 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.42.0
// @version 2.41.0
// @description.markdown api-description.md
// @x-tagGroups [{"name":"Access Control","tags":["auth","roles","team_memberships","teams","users"]},{"name":"Administration","tags":["backup","ldap","motd","settings","status","system","ssl","upload"]},{"name":"Docker","tags":["templates","custom_templates","docker","registries","resource_controls","stacks","webhooks","websocket"]},{"name":"Edge Compute","tags":["edge_groups","edge_jobs","edge","edge_stacks"]},{"name":"Environment Management","tags":["endpoint_groups","endpoints","tags"]},{"name":"GitOps","tags":["gitops"]},{"name":"Kubernetes","tags":["helm","kubernetes"]}]
// @termsOfService
// @contact.email info@portainer.io
@@ -103,100 +104,70 @@ type Handler struct {
// @tag.name auth
// @tag.description Authenticate against Portainer HTTP API
// @tag.x-displayName Authentication
// @tag.name backup
// @tag.description Manage backups
// @tag.x-displayName Backup
// @tag.name custom_templates
// @tag.description Manage Custom Templates
// @tag.x-displayName Custom templates
// @tag.name docker
// @tag.description Manage Docker resources
// @tag.x-displayName Docker resources
// @tag.name edge
// @tag.description Manage Edge related settings
// @tag.x-displayName Edge settings
// @tag.description Manage Edge related environment(endpoint) settings
// @tag.name edge_groups
// @tag.description Manage Edge Groups
// @tag.x-displayName Edge groups
// @tag.name edge_jobs
// @tag.description Manage Edge Jobs
// @tag.x-displayName Edge jobs
// @tag.name edge_stacks
// @tag.description Manage Edge Stacks
// @tag.x-displayName Edge stacks
// @tag.name edge_templates
// @tag.description Manage Edge Templates
// @tag.x-displayName Edge templates
// @tag.name endpoint_groups
// @tag.description Manage environment groups
// @tag.x-displayName Environment groups
// @tag.description Manage environment(endpoint) groups
// @tag.name endpoints
// @tag.description Manage environments
// @tag.x-displayName Environments
// @tag.description Manage Docker environments(endpoints)
// @tag.name gitops
// @tag.description Operate git repository
// @tag.x-displayName GitOps
// @tag.name helm
// @tag.description Manage Helm charts
// @tag.x-displayName Helm charts
// @tag.name intel
// @tag.description Manage Intel AMT settings
// @tag.name kubernetes
// @tag.description Manage Kubernetes cluster
// @tag.x-displayName Kubernetes
// @tag.name ldap
// @tag.description Manage LDAP settings
// @tag.x-displayName LDAP
// @tag.name motd
// @tag.description Fetch the message of the day
// @tag.x-displayName Message of the day
// @tag.name registries
// @tag.description Manage Docker registries
// @tag.x-displayName Registries
// @tag.name resource_controls
// @tag.description Manage access control on Docker resources
// @tag.x-displayName Resource controls
// @tag.name roles
// @tag.description Manage roles
// @tag.x-displayName Roles
// @tag.name settings
// @tag.description Manage Portainer settings
// @tag.x-displayName Portainer settings
// @tag.name ssl
// @tag.description Manage ssl settings
// @tag.x-displayName SSL
// @tag.name stacks
// @tag.description Manage stacks
// @tag.x-displayName Stacks
// @tag.name status
// @tag.description Information about the Portainer instance
// @tag.x-displayName Portainer status
// @tag.name system
// @tag.description Manage Portainer system
// @tag.x-displayName Portainer system
// @tag.name tags
// @tag.description Manage tags
// @tag.x-displayName Tags
// @tag.name team_memberships
// @tag.description Manage team memberships
// @tag.x-displayName Team memberships
// @tag.name teams
// @tag.description Manage teams
// @tag.x-displayName Teams
// @tag.name templates
// @tag.description Manage App Templates
// @tag.x-displayName App templates
// @tag.name upload
// @tag.description Upload files
// @tag.x-displayName Upload files
// @tag.name users
// @tag.description Manage users
// @tag.x-displayName Users
// @tag.name webhooks
// @tag.description Manage webhooks
// @tag.x-displayName Webhooks
// @tag.name websocket
// @tag.description Create exec sessions using websockets
// @tag.x-displayName Websocket
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -273,6 +244,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/ssl"):
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/open_amt"):
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
@@ -0,0 +1,70 @@
package openamt
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id openAMTActivate
// @summary Activate OpenAMT device and associate to agent endpoint
// @description Activate OpenAMT device and associate to agent endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/activate [post]
func (handler *Handler) openAMTActivate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
} else if !endpointutils.IsAgentEndpoint(endpoint) {
errMsg := endpoint.Name + " is not an agent environment"
return httperror.BadRequest(errMsg, errors.New(errMsg))
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
if err := handler.activateDevice(endpoint, *settings); err != nil {
return httperror.InternalServerError("Unable to activate device", err)
}
hostInfo, _, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to retrieve AMT information", err)
}
if hostInfo.ControlModeRaw < 1 {
return httperror.InternalServerError("Failed to activate device", errors.New("failed to activate device"))
}
if hostInfo.UUID == "" {
return httperror.InternalServerError("Unable to retrieve device UUID", errors.New("unable to retrieve device UUID"))
}
endpoint.AMTDeviceGUID = hostInfo.UUID
if err := handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
return response.Empty(w)
}
@@ -0,0 +1,193 @@
package openamt
import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
"software.sslmate.com/src/go-pkcs12"
)
type openAMTConfigurePayload struct {
Enabled bool
MPSServer string
MPSUser string
MPSPassword string
DomainName string
CertFileName string
CertFileContent string
CertFilePassword string
}
func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
if payload.Enabled {
if payload.MPSServer == "" {
return errors.New("MPS Server must be provided")
}
if payload.MPSUser == "" {
return errors.New("MPS User must be provided")
}
if payload.MPSPassword == "" {
return errors.New("MPS Password must be provided")
}
if payload.DomainName == "" {
return errors.New("domain name must be provided")
}
if payload.CertFileContent == "" {
return errors.New("certificate file must be provided")
}
if payload.CertFileName == "" {
return errors.New("certificate file name must be provided")
}
if payload.CertFilePassword == "" {
return errors.New("certificate password must be provided")
}
}
return nil
}
// @id OpenAMTConfigure
// @summary Enable Portainer's OpenAMT capabilities
// @description Enable Portainer's OpenAMT capabilities
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt [post]
func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload openAMTConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
if payload.Enabled {
certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
if certificateErr != nil {
return httperror.BadRequest("Error validating certificate", certificateErr)
}
err = handler.enableOpenAMT(payload)
if err != nil {
return httperror.BadRequest("Error enabling OpenAMT", err)
}
return response.Empty(w)
}
err = handler.disableOpenAMT()
if err != nil {
return httperror.BadRequest("Error disabling OpenAMT", err)
}
return response.Empty(w)
}
func validateCertificate(certificateRaw string, certificatePassword string) error {
certificateData, err := base64.StdEncoding.Strict().DecodeString(certificateRaw)
if err != nil {
return err
}
_, certificate, _, err := pkcs12.DecodeChain(certificateData, certificatePassword)
if err != nil {
return err
}
if certificate == nil {
return errors.New("certificate could not be decoded")
}
issuer := certificate.Issuer.CommonName
if !isValidIssuer(issuer) {
return fmt.Errorf("certificate issuer is invalid: %v", issuer)
}
return nil
}
func isValidIssuer(issuer string) bool {
formattedIssuer := strings.ToLower(strings.ReplaceAll(issuer, " ", ""))
return strings.Contains(formattedIssuer, "comodo") ||
strings.Contains(formattedIssuer, "digicert") ||
strings.Contains(formattedIssuer, "entrust") ||
strings.Contains(formattedIssuer, "godaddy")
}
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
configuration := portainer.OpenAMTConfiguration{
Enabled: true,
MPSServer: configurationPayload.MPSServer,
MPSUser: configurationPayload.MPSUser,
MPSPassword: configurationPayload.MPSPassword,
CertFileContent: configurationPayload.CertFileContent,
CertFileName: configurationPayload.CertFileName,
CertFilePassword: configurationPayload.CertFilePassword,
DomainName: configurationPayload.DomainName,
}
err := handler.OpenAMTService.Configure(configuration)
if err != nil {
log.Error().Err(err).Msg("error configuring OpenAMT server")
return err
}
err = handler.saveConfiguration(configuration)
if err != nil {
log.Error().Err(err).Msg("error updating OpenAMT configurations")
return err
}
log.Info().Msg("OpenAMT successfully enabled")
return nil
}
func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfiguration) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
configuration.MPSToken = ""
settings.OpenAMTConfiguration = configuration
return handler.DataStore.Settings().UpdateSettings(settings)
}
func (handler *Handler) disableOpenAMT() error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
settings.OpenAMTConfiguration.Enabled = false
err = handler.DataStore.Settings().UpdateSettings(settings)
if err != nil {
return err
}
log.Info().Msg("OpenAMT successfully disabled")
return nil
}
@@ -0,0 +1,192 @@
package openamt
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id OpenAMTDevices
// @summary Fetch OpenAMT managed devices information for endpoint
// @description Fetch OpenAMT managed devices information for endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices [get]
func (handler *Handler) openAMTDevices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
}
if endpoint.AMTDeviceGUID == "" {
return response.JSON(w, []portainer.OpenAMTDeviceInformation{})
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
device, err := handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, endpoint.AMTDeviceGUID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve device information", err)
}
devices := []portainer.OpenAMTDeviceInformation{
*device,
}
return response.JSON(w, devices)
}
type deviceActionPayload struct {
Action string
}
func (payload *deviceActionPayload) Validate(r *http.Request) error {
if payload.Action == "" {
return errors.New("device action must be provided")
}
return nil
}
// @id DeviceAction
// @summary Execute out of band action on an AMT managed device
// @description Execute out of band action on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceActionPayload true "Device Action"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices/{deviceId}/action [post]
func (handler *Handler) deviceAction(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return httperror.BadRequest("Invalid device identifier route variable", err)
}
var payload deviceActionPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
err = handler.OpenAMTService.ExecuteDeviceAction(settings.OpenAMTConfiguration, deviceID, payload.Action)
if err != nil {
log.Error().Err(err).Msg("error executing device action")
return httperror.BadRequest("Error executing device action", err)
}
return response.Empty(w)
}
type deviceFeaturesPayload struct {
Features portainer.OpenAMTDeviceEnabledFeatures
}
func (payload *deviceFeaturesPayload) Validate(r *http.Request) error {
if payload.Features.UserConsent == "" {
return errors.New("device user consent status must be provided")
}
return nil
}
type AuthorizationResponse struct {
Server string
Token string
}
// @id DeviceFeatures
// @summary Enable features on an AMT managed device
// @description Enable features on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceFeaturesPayload true "Device Features"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices_features/{deviceId} [post]
func (handler *Handler) deviceFeatures(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return httperror.BadRequest("Invalid device identifier route variable", err)
}
var payload deviceFeaturesPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
log.Error().Err(err).Msg("invalid request payload")
return httperror.BadRequest("Invalid request payload", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
_, err = handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, deviceID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve device information", err)
}
token, err := handler.OpenAMTService.EnableDeviceFeatures(settings.OpenAMTConfiguration, deviceID, payload.Features)
if err != nil {
log.Error().Err(err).Msg("error executing device action")
return httperror.BadRequest("Error executing device action", err)
}
authorizationResponse := AuthorizationResponse{
Server: settings.OpenAMTConfiguration.MPSServer,
Token: token,
}
return response.JSON(w, authorizationResponse)
}
@@ -0,0 +1,306 @@
package openamt
import (
"context"
"fmt"
"io"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"github.com/portainer/portainer/api/logs"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type HostInfo struct {
EndpointID portainer.EndpointID `json:"EndpointID"`
RawOutput string `json:"RawOutput"`
AMT string `json:"AMT"`
UUID string `json:"UUID"`
DNSSuffix string `json:"DNS Suffix"`
BuildNumber string `json:"Build Number"`
ControlMode string `json:"Control Mode"`
ControlModeRaw int `json:"Control Mode (Raw)"`
}
const (
// TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
rpcGoImageName = "ptrrd/openamt:rpc-go-json"
rpcGoContainerName = "openamt-rpc-go"
dockerClientTimeout = 5 * time.Minute
)
// @id OpenAMTHostInfo
// @summary Request OpenAMT info from a node
// @description Request OpenAMT info from a node
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/info [get]
func (handler *Handler) openAMTHostInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
log.Info().Int("endpointID", endpointID).Msg("OpenAMTHostInfo")
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an endpoint with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
}
amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return httperror.InternalServerError(output, err)
}
return response.JSON(w, amtInfo)
}
func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
ctx := context.TODO()
// pull the image so we can check if there's a new one
// TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
cmdLine := []string{"amtinfo", "--json"}
output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return nil, output, err
}
amtInfo := HostInfo{}
_ = json.Unmarshal([]byte(output), &amtInfo)
amtInfo.EndpointID = endpoint.ID
amtInfo.RawOutput = output
return &amtInfo, "", nil
}
func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
// TODO: this should not be Docker specific
// TODO: extract from this Handler into something global.
// TODO: start
// docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
// on the Docker standalone node (one per env :)
// and later, on the specified node in the swarm, or kube.
nodeName := ""
timeout := dockerClientTimeout
docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
if err != nil {
return "Unable to create Docker Client connection", err
}
defer func() {
if err := docker.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close docker client")
}
}()
if err := pullImage(ctx, docker, imageName); err != nil {
return "Could not pull image from registry", err
}
output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
if err != nil {
return "Could not run container", err
}
return output, nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
// pullImage will pull the image to the specified environment
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not pull image from registry")
return err
}
defer logs.CloseAndLogErr(out)
outputBytes, err := io.ReadAll(out)
if err != nil {
log.Error().Str("image_name", imageName).Err(err).Msg("could not read image pull output")
return err
}
log.Debug().Str("image_name", imageName).Str("output", string(outputBytes)).Msg("image pulled")
return nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := container.ListOptions{All: true}
opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("listing existing container")
return "", err
}
if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("removing existing container")
return "", err
}
}
created, err := docker.ContainerCreate(
ctx,
&container.Config{
Image: imageName,
Cmd: cmdLine,
Env: []string{},
Tty: true,
OpenStdin: true,
AttachStdout: true,
AttachStderr: true,
},
&container.HostConfig{
Privileged: true,
},
&network.NetworkingConfig{},
nil,
containerName,
)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("creating container")
return "", err
}
err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("starting container")
return "", err
}
log.Debug().Str("container_name", containerName).Msg("container created and started")
statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-errCh:
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("starting container")
return "", err
}
case status := <-statusCh:
statusCode = status.StatusCode
}
log.Debug().Int64("status", statusCode).Msg("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
if err != nil {
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("removing container")
return "", err
}
outputBytes, err := io.ReadAll(out)
if err != nil {
log.Error().
Str("image_name", imageName).
Str("container_name", containerName).
Err(err).
Msg("read container output")
return "", err
}
log.Debug().
Str("container_name", containerName).
Str("output", string(outputBytes)).
Msg("container finished with output")
return string(outputBytes), nil
}
func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"activate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-profile", openamt.DefaultProfileName,
"-d", config.DomainName,
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
return err
}
@@ -0,0 +1,39 @@
package openamt
import (
"net/http"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// Handler is the HTTP handler used to handle OpenAMT operations.
type Handler struct {
*mux.Router
OpenAMTService portainer.OpenAMTService
DataStore dataservices.DataStore
DockerClientFactory *dockerclient.ClientFactory
}
// NewHandler returns a new Handler
func NewHandler(bouncer security.BouncerService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Use(middlewares.DeprecatedSimple)
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
return h
}
-17
View File
@@ -94,17 +94,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/persistent_volumes", httperror.LoggerHandler(h.getAllKubernetesPersistentVolumes)).Methods(http.MethodGet)
endpointRouter.Handle("/persistent_volumes/delete", httperror.LoggerHandler(h.deleteKubernetesPersistentVolumes)).Methods(http.MethodPost)
endpointRouter.Handle("/persistent_volumes/reclaim_policy", httperror.LoggerHandler(h.updateKubernetesPVReclaimPolicy)).Methods(http.MethodPut)
endpointRouter.Handle("/persistent_volumes/{name}", httperror.LoggerHandler(h.getKubernetesPersistentVolume)).Methods(http.MethodGet)
endpointRouter.Handle("/persistent_volume_claims", httperror.LoggerHandler(h.getAllKubernetesPersistentVolumeClaims)).Methods(http.MethodGet)
endpointRouter.Handle("/persistent_volume_claims/delete", httperror.LoggerHandler(h.deleteKubernetesPersistentVolumeClaims)).Methods(http.MethodPost)
endpointRouter.Handle("/persistent_volume_claims/resize", httperror.LoggerHandler(h.resizeKubernetesPersistentVolumeClaim)).Methods(http.MethodPut)
endpointRouter.Handle("/storage_classes", httperror.LoggerHandler(h.getAllKubernetesStorageClasses)).Methods(http.MethodGet)
endpointRouter.Handle("/storage_classes/delete", httperror.LoggerHandler(h.deleteKubernetesStorageClasses)).Methods(http.MethodPost)
endpointRouter.Handle("/storage_classes/{name}", httperror.LoggerHandler(h.getKubernetesStorageClass)).Methods(http.MethodGet)
endpointRouter.Handle("/storage_classes/{name}/default", httperror.LoggerHandler(h.setDefaultKubernetesStorageClass)).Methods(http.MethodPut)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost)
endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet)
@@ -117,7 +106,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
endpointRouter.Handle("/nodes/{name}/drain", httperror.LoggerHandler(h.drainNode)).Methods(http.MethodPost)
endpointRouter.Handle("/version", httperror.LoggerHandler(h.getKubernetesVersion)).Methods(http.MethodGet)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
@@ -137,13 +125,8 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/service_accounts/{name}", httperror.LoggerHandler(h.getKubernetesServiceAccount)).Methods(http.MethodGet)
namespaceRouter.Handle("/service_accounts/{name}/image_pull_secrets", httperror.LoggerHandler(h.updateKubernetesServiceAccountImagePullSecrets)).Methods(http.MethodPut)
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
namespaceRouter.Handle("/persistent_volume_claims", httperror.LoggerHandler(h.getKubernetesPVCsInNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/persistent_volume_claims/{name}", httperror.LoggerHandler(h.getKubernetesPersistentVolumeClaim)).Methods(http.MethodGet)
namespaceRouter.Handle("/pods/{name}", httperror.LoggerHandler(h.deleteKubernetesPod)).Methods(http.MethodDelete)
namespaceRouter.Handle("/pods/{name}/restart", httperror.LoggerHandler(h.restartKubernetesPod)).Methods(http.MethodPost)
// Deprecated
endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut)
@@ -1,227 +0,0 @@
package kubernetes
import (
"errors"
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
kcli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GetAllKubernetesPersistentVolumeClaims
// @summary Get all PersistentVolumeClaims
// @description Get a list of all PersistentVolumeClaims within the given environment. Scoped by namespace for non-admin users.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sPersistentVolumeClaim "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve persistent volume claims."
// @router /kubernetes/{id}/persistent_volume_claims [get]
func (handler *Handler) getAllKubernetesPersistentVolumeClaims(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Unable to get Kubernetes client")
return httpErr
}
pvcs, err := cli.GetPersistentVolumeClaims("")
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to persistent volume claims", err)
}
log.Error().Err(err).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Failed to retrieve persistent volume claims")
return httperror.InternalServerError("failed to retrieve persistent volume claims", err)
}
return response.JSON(w, pvcs)
}
// @id GetKubernetesPersistentVolumeClaimsInNamespace
// @summary Get PersistentVolumeClaims in a namespace
// @description Get a list of PersistentVolumeClaims in the specified namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace name"
// @success 200 {array} models.K8sPersistentVolumeClaim "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve persistent volume claims."
// @router /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims [get]
func (handler *Handler) getKubernetesPVCsInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return httperror.BadRequest("invalid namespace identifier", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesPVCsInNamespace").Msg("Unable to get Kubernetes client")
return httpErr
}
pvcs, err := cli.GetPersistentVolumeClaims(namespace)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to persistent volume claims", err)
}
log.Error().Err(err).Str("context", "GetKubernetesPVCsInNamespace").Str("namespace", namespace).Msg("Failed to retrieve persistent volume claims")
return httperror.InternalServerError("failed to retrieve persistent volume claims", err)
}
return response.JSON(w, pvcs)
}
// @id GetKubernetesPersistentVolumeClaim
// @summary Get a specific PersistentVolumeClaim
// @description Get a PersistentVolumeClaim by name within a namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace name"
// @param name path string true "PVC name"
// @success 200 {object} models.K8sPersistentVolumeClaim "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "PVC not found."
// @failure 500 "Server error occurred while attempting to retrieve the persistent volume claim."
// @router /kubernetes/{id}/namespaces/{namespace}/persistent_volume_claims/{name} [get]
func (handler *Handler) getKubernetesPersistentVolumeClaim(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return httperror.BadRequest("invalid namespace identifier", err)
}
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("invalid PVC name", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesPersistentVolumeClaim").Msg("Unable to get Kubernetes client")
return httpErr
}
pvc, err := cli.GetPersistentVolumeClaim(namespace, name)
if err != nil {
if k8serrors.IsNotFound(err) {
return httperror.NotFound("persistent volume claim not found", err)
}
if errors.Is(err, kcli.ErrUnauthorized) || k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "GetKubernetesPersistentVolumeClaim").Str("namespace", namespace).Str("name", name).Msg("Failed to retrieve persistent volume claim")
return httperror.InternalServerError("failed to retrieve persistent volume claim", err)
}
return response.JSON(w, pvc)
}
// @id DeleteKubernetesPersistentVolumeClaims
// @summary Delete PersistentVolumeClaims
// @description Delete the provided list of PersistentVolumeClaims.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param body body []models.K8sVolumeDeleteRequest true "List of PVCs to delete (namespace + name)"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete persistent volume claims."
// @router /kubernetes/{id}/persistent_volume_claims/delete [post]
func (handler *Handler) deleteKubernetesPersistentVolumeClaims(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sVolumeDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("unable to decode and validate the request payload", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.DeletePersistentVolumeClaims(payload)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("unable to find the persistent volume claims to delete", err)
}
log.Error().Err(err).Str("context", "DeleteKubernetesPersistentVolumeClaims").Msg("Unable to delete persistent volume claims")
return httperror.InternalServerError("unable to delete persistent volume claims", err)
}
return response.Empty(w)
}
// @id ResizeKubernetesPersistentVolumeClaim
// @summary Resize a PersistentVolumeClaim
// @description Resize a PVC to a new size. The StorageClass must support volume expansion.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param body body models.K8sPVCResizeRequest true "PVC resize request"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to resize the persistent volume claim."
// @router /kubernetes/{id}/persistent_volume_claims/resize [put]
func (handler *Handler) resizeKubernetesPersistentVolumeClaim(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sPVCResizeRequest
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("unable to decode and validate the request payload", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.ResizePersistentVolumeClaim(payload.Namespace, payload.Name, payload.NewSize)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("persistent volume claim not found", err)
}
log.Error().Err(err).Str("context", "ResizeKubernetesPersistentVolumeClaim").
Str("namespace", payload.Namespace).Str("name", payload.Name).
Msg("Unable to resize persistent volume claim")
return httperror.InternalServerError("unable to resize persistent volume claim", err)
}
return response.Empty(w)
}
@@ -1,180 +0,0 @@
package kubernetes
import (
"errors"
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
kcli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GetAllKubernetesPersistentVolumes
// @summary Get all PersistentVolumes in the cluster
// @description Get a list of all PersistentVolumes in the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sPersistentVolume "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve persistent volumes."
// @router /kubernetes/{id}/persistent_volumes [get]
func (handler *Handler) getAllKubernetesPersistentVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesPersistentVolumes").Msg("Unable to get Kubernetes client")
return httpErr
}
pvs, err := cli.GetPersistentVolumes()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to persistent volumes", err)
}
log.Error().Err(err).Str("context", "GetAllKubernetesPersistentVolumes").Msg("Failed to retrieve persistent volumes")
return httperror.InternalServerError("failed to retrieve persistent volumes", err)
}
return response.JSON(w, pvs)
}
// @id GetKubernetesPersistentVolume
// @summary Get a specific PersistentVolume
// @description Get a PersistentVolume by name in the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param name path string true "PersistentVolume name"
// @success 200 {object} models.K8sPersistentVolume "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "PersistentVolume not found."
// @failure 500 "Server error occurred while attempting to retrieve the persistent volume."
// @router /kubernetes/{id}/persistent_volumes/{name} [get]
func (handler *Handler) getKubernetesPersistentVolume(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("invalid persistent volume name", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesPersistentVolume").Msg("Unable to get Kubernetes client")
return httpErr
}
pv, err := cli.GetPersistentVolume(name)
if err != nil {
if k8serrors.IsNotFound(err) {
return httperror.NotFound("persistent volume not found", err)
}
if errors.Is(err, kcli.ErrUnauthorized) || k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "GetKubernetesPersistentVolume").Str("name", name).Msg("Failed to retrieve persistent volume")
return httperror.InternalServerError("failed to retrieve persistent volume", err)
}
return response.JSON(w, pv)
}
// @id DeleteKubernetesPersistentVolumes
// @summary Delete PersistentVolumes
// @description Delete the provided list of PersistentVolumes.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param body body models.K8sPVDeleteRequest true "List of PV names to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete persistent volumes."
// @router /kubernetes/{id}/persistent_volumes/delete [post]
func (handler *Handler) deleteKubernetesPersistentVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sPVDeleteRequest
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("unable to decode and validate the request payload", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.DeletePersistentVolumes(payload)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("unable to find the persistent volumes to delete", err)
}
log.Error().Err(err).Str("context", "DeleteKubernetesPersistentVolumes").Msg("Unable to delete persistent volumes")
return httperror.InternalServerError("unable to delete persistent volumes", err)
}
return response.Empty(w)
}
// @id UpdateKubernetesPersistentVolumeReclaimPolicy
// @summary Update reclaim policy of a PersistentVolume
// @description Update the reclaim policy of a PersistentVolume.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param body body models.K8sPVReclaimPolicyRequest true "Reclaim policy update request"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to update reclaim policy."
// @router /kubernetes/{id}/persistent_volumes/reclaim_policy [put]
func (handler *Handler) updateKubernetesPVReclaimPolicy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sPVReclaimPolicyRequest
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("unable to decode and validate the request payload", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.UpdatePersistentVolumeReclaimPolicy(payload.Name, payload.ReclaimPolicy)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("persistent volume not found", err)
}
log.Error().Err(err).Str("context", "UpdateKubernetesPVReclaimPolicy").Str("name", payload.Name).Msg("Unable to update reclaim policy")
return httperror.InternalServerError("unable to update reclaim policy", err)
}
return response.Empty(w)
}
-127
View File
@@ -1,127 +0,0 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
// @id DeleteKubernetesPod
// @summary Delete a kubernetes pod
// @description Delete a single Kubernetes pod in the given namespace. The owning
// @description controller (Deployment, StatefulSet, DaemonSet, ...) is responsible
// @description for recreating the pod. For naked pods the pod is removed permanently.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param namespace path string true "Namespace"
// @param name path string true "Pod name"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find the specified pod."
// @failure 500 "Server error occurred while attempting to delete the pod."
// @router /kubernetes/{id}/namespaces/{namespace}/pods/{name} [delete]
func (handler *Handler) deleteKubernetesPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, name, httpErr := parseNamespaceAndPodName(r)
if httpErr != nil {
return httpErr
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
if err := cli.DeletePod(namespace, name); err != nil {
if k8serrors.IsNotFound(err) {
log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod not found")
return httperror.NotFound("Pod not found", err)
}
if k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Permission denied to delete the pod")
return httperror.Forbidden("Permission denied to delete the pod", err)
}
log.Error().Err(err).Str("context", "DeleteKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to delete the pod")
return httperror.InternalServerError("Unable to delete the pod", err)
}
return response.Empty(w)
}
// @id RestartKubernetesPod
// @summary Restart all containers in a Kubernetes pod
// @description Restart all containers in a single Kubernetes pod in place using
// @description the Kubernetes 1.35 alpha pod-restart subresource. The pod itself
// @description is preserved. Requires the cluster to expose the corresponding
// @description subresource (and the matching feature gate to be enabled).
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param namespace path string true "Namespace"
// @param name path string true "Pod name"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment, the specified pod, or the cluster does not expose the pod-restart subresource (Kubernetes <1.35 or feature gate disabled)."
// @failure 405 "The cluster does not support the pod-restart subresource (Kubernetes <1.35 or feature gate disabled)."
// @failure 500 "Server error occurred while attempting to restart the pod."
// @router /kubernetes/{id}/namespaces/{namespace}/pods/{name}/restart [post]
func (handler *Handler) restartKubernetesPod(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, name, httpErr := parseNamespaceAndPodName(r)
if httpErr != nil {
return httpErr
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
if err := cli.RestartPod(namespace, name); err != nil {
if k8serrors.IsNotFound(err) {
log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod or pod-restart subresource not found")
return httperror.NotFound("Pod or pod-restart subresource not found. The Kubernetes 1.35 alpha pod-restart subresource is required and may need its feature gate enabled.", err)
}
if k8serrors.IsMethodNotSupported(err) {
log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Pod-restart subresource not supported")
return httperror.NewError(http.StatusMethodNotAllowed, "The cluster does not support the pod-restart subresource (Kubernetes <1.35 or feature gate disabled).", err)
}
if k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Permission denied to restart the pod")
return httperror.Forbidden("Permission denied to restart the pod", err)
}
log.Error().Err(err).Str("context", "RestartKubernetesPod").Str("namespace", namespace).Str("name", name).Msg("Unable to restart the pod")
return httperror.InternalServerError("Unable to restart the pod", err)
}
return response.Empty(w)
}
func parseNamespaceAndPodName(r *http.Request) (string, string, *httperror.HandlerError) {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "parseNamespaceAndPodName").Msg("Invalid namespace route variable")
return "", "", httperror.BadRequest("Invalid namespace route variable", err)
}
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
log.Error().Err(err).Str("context", "parseNamespaceAndPodName").Msg("Invalid pod name route variable")
return "", "", httperror.BadRequest("Invalid pod name route variable", err)
}
return namespace, name, nil
}
-131
View File
@@ -1,131 +0,0 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newPodTestHandler(t *testing.T) (*Handler, *portainer.User, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
_, store := datastore.MustNewTestStore(t, true, true)
// KubernetesLocalEnvironment avoids the nil signatureService panic that
// AgentOnKubernetesEnvironment triggers via buildAgentConfig.
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.KubernetesLocalEnvironment,
})
require.NoError(t, err)
u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
err = store.User().Create(u)
require.NoError(t, err)
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
require.NoError(t, err)
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
cli := testhelpers.NewKubernetesClient()
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
require.NoError(t, err)
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
return handler, u, tk
}
func newPodRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
return req
}
func TestDeleteKubernetesPod_ReachesKubernetesLayer(t *testing.T) {
t.Parallel()
handler, u, tk := newPodTestHandler(t)
req := newPodRequest(t, http.MethodDelete, "/kubernetes/1/namespaces/default/pods/my-pod", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// A valid request must pass all handler-level checks. Without a live
// cluster the privileged kube client cannot be created, so we expect 500
// rather than a 4xx client error.
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer")
assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered")
assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable")
}
func TestDeleteKubernetesPod_WrongMethodReturns404(t *testing.T) {
t.Parallel()
handler, u, tk := newPodTestHandler(t)
req := newPodRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/pods/my-pod", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Gorilla mux returns 404 for unregistered methods when no MethodNotAllowedHandler is set.
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestRestartKubernetesPod_ReachesKubernetesLayer(t *testing.T) {
t.Parallel()
handler, u, tk := newPodTestHandler(t)
req := newPodRequest(t, http.MethodPost, "/kubernetes/1/namespaces/default/pods/my-pod/restart", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer")
assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered")
assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable")
}
func TestRestartKubernetesPod_WrongMethodReturns404(t *testing.T) {
t.Parallel()
handler, u, tk := newPodTestHandler(t)
req := newPodRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/pods/my-pod/restart", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
@@ -8,7 +8,6 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
// @id GetKubernetesServiceAccounts
@@ -118,55 +117,3 @@ func (handler *Handler) deleteKubernetesServiceAccounts(w http.ResponseWriter, r
return response.Empty(w)
}
// @id UpdateKubernetesServiceAccountImagePullSecrets
// @summary Update image pull secrets for a service account
// @description Replace the imagePullSecrets list on a service account with the provided list.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param namespace path string true "Namespace"
// @param name path string true "Service account name"
// @param payload body models.K8sServiceAccountImagePullSecretsUpdatePayload true "New imagePullSecrets list"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier, namespace, or service account."
// @failure 500 "Server error occurred while attempting to update image pull secrets for the service account."
// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name}/image_pull_secrets [put]
func (handler *Handler) updateKubernetesServiceAccountImagePullSecrets(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
return httperror.BadRequest("Invalid namespace", err)
}
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("Invalid name", err)
}
var payload models.K8sServiceAccountImagePullSecretsUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
if err := cli.UpdateServiceAccountImagePullSecrets(namespace, name, payload.SecretNames); err != nil {
if k8serrors.IsNotFound(err) {
log.Error().Err(err).Str("context", "UpdateKubernetesServiceAccountImagePullSecrets").Msg("Unable to find service account")
return httperror.NotFound("Unable to find service account", err)
}
log.Error().Err(err).Str("context", "UpdateKubernetesServiceAccountImagePullSecrets").Msg("Unable to update image pull secrets")
return httperror.InternalServerError("Unable to update image pull secrets", err)
}
return response.Empty(w)
}
@@ -141,80 +141,3 @@ func TestDeleteKubernetesServiceAccounts_EmptyNamespace(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, string(bodyData), "should have error response body")
}
func TestUpdateKubernetesServiceAccountImagePullSecrets_ValidPayload(t *testing.T) {
t.Parallel()
handler, u, tk := newServiceAccountTestHandler(t)
payload := map[string][]string{"secretNames": {"secret-1", "secret-2"}}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload")
}
func TestUpdateKubernetesServiceAccountImagePullSecrets_InvalidPayload(t *testing.T) {
t.Parallel()
handler, u, tk := newServiceAccountTestHandler(t)
req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", []byte("not-json"), u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for malformed JSON")
}
func TestUpdateKubernetesServiceAccountImagePullSecrets_EmptySecretNames(t *testing.T) {
t.Parallel()
handler, u, tk := newServiceAccountTestHandler(t)
payload := map[string][]string{"secretNames": {}}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "empty secretNames should be valid (clears all imagePullSecrets)")
}
func TestUpdateKubernetesServiceAccountImagePullSecrets_ReachesKubernetesLayer(t *testing.T) {
t.Parallel()
// Verifies that valid JSON passes all handler-level checks and reaches the
// Kubernetes layer. Without a live cluster the proxy client is unavailable,
// so we get 500 — not a 4xx client error.
handler, u, tk := newServiceAccountTestHandler(t)
payload := map[string][]string{"secretNames": {"secret-1", "secret-2"}}
body, err := json.Marshal(payload)
require.NoError(t, err)
req := newServiceAccountRequest(t, http.MethodPut, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", body, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "valid payload must not be rejected at the handler layer")
assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the proxy client is unavailable")
}
func TestUpdateKubernetesServiceAccountImagePullSecrets_WrongMethodReturns404(t *testing.T) {
t.Parallel()
handler, u, tk := newServiceAccountTestHandler(t)
req := newServiceAccountRequest(t, http.MethodGet, "/kubernetes/1/namespaces/default/service_accounts/my-sa/image_pull_secrets", nil, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Gorilla mux returns 404 (not 405) for method mismatches when no MethodNotAllowedHandler is set
assert.Equal(t, http.StatusNotFound, rr.Code, "unregistered method on this route returns 404")
}
@@ -1,177 +0,0 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GetAllKubernetesStorageClasses
// @summary Get all StorageClasses
// @description Get a list of all StorageClasses in the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sStorageClass "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to retrieve storage classes."
// @router /kubernetes/{id}/storage_classes [get]
func (handler *Handler) getAllKubernetesStorageClasses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesStorageClasses").Msg("Unable to get Kubernetes client")
return httpErr
}
storageClasses, err := cli.GetStorageClasses()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to storage classes", err)
}
log.Error().Err(err).Str("context", "GetAllKubernetesStorageClasses").Msg("Failed to retrieve storage classes")
return httperror.InternalServerError("failed to retrieve storage classes", err)
}
return response.JSON(w, storageClasses)
}
// @id GetKubernetesStorageClass
// @summary Get a specific StorageClass
// @description Get a StorageClass by name in the given environment.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param name path string true "StorageClass name"
// @success 200 {object} models.K8sStorageClass "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "StorageClass not found."
// @failure 500 "Server error occurred while attempting to retrieve the storage class."
// @router /kubernetes/{id}/storage_classes/{name} [get]
func (handler *Handler) getKubernetesStorageClass(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("invalid storage class name", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesStorageClass").Msg("Unable to get Kubernetes client")
return httpErr
}
sc, err := cli.GetStorageClass(name)
if err != nil {
if k8serrors.IsNotFound(err) {
return httperror.NotFound("storage class not found", err)
}
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "GetKubernetesStorageClass").Str("name", name).Msg("Failed to retrieve storage class")
return httperror.InternalServerError("failed to retrieve storage class", err)
}
return response.JSON(w, sc)
}
// @id DeleteKubernetesStorageClasses
// @summary Delete StorageClasses
// @description Delete the provided list of StorageClasses.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param body body models.K8sStorageClassDeleteRequest true "List of StorageClass names to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete storage classes."
// @router /kubernetes/{id}/storage_classes/delete [post]
func (handler *Handler) deleteKubernetesStorageClasses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sStorageClassDeleteRequest
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("unable to decode and validate the request payload", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.DeleteStorageClasses(payload)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("unable to find the storage classes to delete", err)
}
log.Error().Err(err).Str("context", "DeleteKubernetesStorageClasses").Msg("Unable to delete storage classes")
return httperror.InternalServerError("unable to delete storage classes", err)
}
return response.Empty(w)
}
// @id SetDefaultKubernetesStorageClass
// @summary Set a StorageClass as default
// @description Set the specified StorageClass as the cluster default, removing default from any other.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param id path int true "Environment identifier"
// @param name path string true "StorageClass name"
// @success 204 "Success"
// @failure 400 "Invalid request payload."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to set default storage class."
// @router /kubernetes/{id}/storage_classes/{name}/default [put]
func (handler *Handler) setDefaultKubernetesStorageClass(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
name, err := request.RetrieveRouteVariableValue(r, "name")
if err != nil {
return httperror.BadRequest("invalid storage class name", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
return httpErr
}
err = cli.SetDefaultStorageClass(name)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
return httperror.Forbidden("unauthorized access to the Kubernetes API", err)
}
if k8serrors.IsNotFound(err) {
return httperror.NotFound("storage class not found", err)
}
log.Error().Err(err).Str("context", "SetDefaultKubernetesStorageClass").Str("name", name).Msg("Unable to set default storage class")
return httperror.InternalServerError("unable to set default storage class", err)
}
return response.Empty(w)
}
-74
View File
@@ -1,74 +0,0 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/version"
)
// kubernetesVersionResponse is the shape returned by GetKubernetesVersion.
// It augments the standard Kubernetes /version payload with capability
// flags computed by Portainer (e.g. whether the API server registers the
// 1.35 alpha pod-restart subresource) so the UI can gate features without
// a second round-trip.
type kubernetesVersionResponse struct {
*version.Info
// SupportsPodRestart is true when the cluster exposes the `pods/restart`
// subresource via API discovery — i.e. the feature gate is enabled and
// the cluster version is recent enough. This is the authoritative
// signal for whether Portainer can call the pod-restart endpoint, and
// is preferred over a raw Kubernetes-version comparison.
SupportsPodRestart bool `json:"supportsPodRestart"`
}
// @id GetKubernetesVersion
// @summary Get the Kubernetes cluster version and Portainer-relevant capabilities
// @description Get the Kubernetes cluster version (major, minor, gitVersion, ...)
// @description as reported by the cluster's discovery API, augmented with capability
// @description flags Portainer uses to gate UI features (e.g. supportsPodRestart).
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 {object} kubernetesVersionResponse "Success"
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the cluster version."
// @router /kubernetes/{id}/version [get]
func (handler *Handler) getKubernetesVersion(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesVersion").Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
info, err := cli.ServerVersion()
if err != nil {
if k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "GetKubernetesVersion").Msg("Permission denied to retrieve cluster version")
return httperror.Forbidden("Permission denied to retrieve cluster version", err)
}
log.Error().Err(err).Str("context", "GetKubernetesVersion").Msg("Unable to retrieve cluster version")
return httperror.InternalServerError("Unable to retrieve cluster version", err)
}
// A discovery failure shouldn't fail the whole request — the cluster
// version is still useful to the caller. We log it and treat the
// capability as unsupported (safe default that hides the action).
supportsPodRestart, err := cli.SupportsPodRestart()
if err != nil {
log.Warn().Err(err).Str("context", "GetKubernetesVersion").Msg("Unable to probe pod-restart subresource via API discovery; assuming unsupported")
supportsPodRestart = false
}
return response.JSON(w, kubernetesVersionResponse{
Info: info,
SupportsPodRestart: supportsPodRestart,
})
}
-115
View File
@@ -1,115 +0,0 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newVersionTestHandler(t *testing.T) (*Handler, *portainer.User, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
_, store := datastore.MustNewTestStore(t, true, true)
// KubernetesLocalEnvironment avoids the nil signatureService panic that
// AgentOnKubernetesEnvironment triggers via buildAgentConfig.
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.KubernetesLocalEnvironment,
})
require.NoError(t, err)
u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
err = store.User().Create(u)
require.NoError(t, err)
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
require.NoError(t, err)
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
cli := testhelpers.NewKubernetesClient()
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
require.NoError(t, err)
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
return handler, u, tk
}
func newVersionRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
return req
}
func TestGetKubernetesVersion_ReachesKubernetesLayer(t *testing.T) {
t.Parallel()
handler, u, tk := newVersionTestHandler(t)
req := newVersionRequest(t, http.MethodGet, "/kubernetes/1/version", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// A valid GET must not be rejected at the handler layer. Without a live
// cluster the privileged kube client cannot be created (500), not a 4xx.
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not be rejected at the handler layer")
assert.NotEqual(t, http.StatusNotFound, rr.Code, "route must be registered")
assert.Equal(t, http.StatusInternalServerError, rr.Code, "without a live cluster the kube client is unavailable")
}
func TestGetKubernetesVersion_WrongMethodReturns404(t *testing.T) {
t.Parallel()
handler, u, tk := newVersionTestHandler(t)
req := newVersionRequest(t, http.MethodPost, "/kubernetes/1/version", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestGetKubernetesVersion_DeleteMethodReturns404(t *testing.T) {
t.Parallel()
handler, u, tk := newVersionTestHandler(t)
req := newVersionRequest(t, http.MethodDelete, "/kubernetes/1/version", u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
@@ -32,6 +32,9 @@ type publicSettingsResponse struct {
// Whether team sync is enabled
TeamSync bool `json:"TeamSync" example:"true"`
// Whether AMT is enabled
IsAMTEnabled bool
Edge struct {
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
@@ -75,6 +78,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
GlobalDeploymentOptions: appSettings.GlobalDeploymentOptions,
KubeconfigExpiry: appSettings.KubeconfigExpiry,
Features: featureflags.FeatureFlags(),
IsAMTEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.OpenAMTConfiguration.Enabled,
}
publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval
@@ -53,8 +53,6 @@ type settingsUpdatePayload struct {
EnforceEdgeID *bool `example:"false"`
// EdgePortainerURL is the URL that is exposed to edge agents
EdgePortainerURL *string `json:"EdgePortainerURL"`
// ForceSecureCookies forces the Secure attribute on auth cookies regardless of the detected scheme
ForceSecureCookies *bool `example:"false"`
}
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
@@ -204,7 +202,6 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.TrustOnFirstConnect = *cmp.Or(payload.TrustOnFirstConnect, &settings.TrustOnFirstConnect)
settings.EnforceEdgeID = *cmp.Or(payload.EnforceEdgeID, &settings.EnforceEdgeID)
settings.EdgePortainerURL = *cmp.Or(payload.EdgePortainerURL, &settings.EdgePortainerURL)
settings.ForceSecureCookies = *cmp.Or(payload.ForceSecureCookies, &settings.ForceSecureCookies)
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
if err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval); err != nil {
@@ -160,7 +160,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
handler.FileService,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
@@ -297,7 +297,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
handler.Scheduler,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
@@ -383,7 +383,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
handler.FileService,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, composeStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
@@ -168,7 +168,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
}
}
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil {
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint); err != nil {
return err
}
@@ -240,7 +240,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
handler.KubernetesDeployer,
user)
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil {
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint); err != nil {
return err
}
@@ -285,7 +285,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
handler.KubernetesDeployer,
user)
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint, userID); err != nil {
if _, err := stackbuilders.Build(r.Context(), handler.DataStore, k8sStackBuilder, &stackPayload, endpoint); err != nil {
return err
}
@@ -96,7 +96,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
handler.FileService,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
@@ -237,7 +237,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
handler.Scheduler,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
@@ -337,7 +337,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
handler.FileService,
handler.StackDeployer)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint, userID)
stack, httpErr := stackbuilders.Build(r.Context(), handler.DataStore, swarmStackBuilder, &stackPayload, endpoint)
if httpErr != nil {
return httpErr
}
-1
View File
@@ -72,7 +72,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
restrictedRouter.Handle("/users/{id}/tokens", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userCreateAccessToken))).Methods(http.MethodPost)
restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete)
restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/{id}/effective-access", httperror.LoggerHandler(h.userEffectiveAccess)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut)
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
@@ -1,169 +0,0 @@
package users
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/authorization"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// AccessLocation describes which part of the access model granted the user
// their effective role on an environment. The frontend maps these enum values
// to display strings.
type AccessLocation string
const (
AccessLocationEnvironment AccessLocation = "environment"
AccessLocationEnvironmentGroup AccessLocation = "environmentGroup"
)
// @id UserEffectiveAccessInspect
// @summary Inspect a user's effective access on every environment
// @description Returns the resolved role for each environment the user can access,
// @description following the policy precedence used by the access viewer
// @description (user-endpoint, user-group, team-endpoint, team-group).
// @description Environments where the user has no role are omitted.
// @description **Access policy**: restricted
// @tags users
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "User identifier"
// @success 200 {array} EffectiveAccessEntry "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "User not found"
// @failure 500 "Server error"
// @router /users/{id}/effective-access [get]
func (handler *Handler) userEffectiveAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
userID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid user identifier route variable", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) {
return httperror.Forbidden("Permission denied to inspect another user's effective access", errors.ErrUnauthorized)
}
entries := make([]EffectiveAccessEntry, 0)
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
user, err := tx.User().Read(portainer.UserID(userID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
endpoints, err := tx.Endpoint().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
groups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
roles, err := tx.Role().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve roles from the database", err)
}
teams, err := tx.Team().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
}
memberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve team memberships from the database", err)
}
groupsByID := make(map[portainer.EndpointGroupID]portainer.EndpointGroup, len(groups))
for _, g := range groups {
groupsByID[g.ID] = g
}
teamsByID := make(map[portainer.TeamID]portainer.Team, len(teams))
for _, t := range teams {
teamsByID[t.ID] = t
}
for i := range endpoints {
endpoint := &endpoints[i]
access := authorization.ResolveUserEndpointAccess(authorization.ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groupsByID[endpoint.GroupID],
Roles: roles,
UserMemberships: memberships,
})
if access == nil {
continue
}
entries = append(entries, buildEffectiveAccessEntry(access, endpoint, groupsByID, teamsByID))
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
return response.JSON(w, entries)
}
func buildEffectiveAccessEntry(
access *authorization.ResolvedAccess,
endpoint *portainer.Endpoint,
groupsByID map[portainer.EndpointGroupID]portainer.EndpointGroup,
teamsByID map[portainer.TeamID]portainer.Team,
) EffectiveAccessEntry {
entry := EffectiveAccessEntry{
EndpointID: endpoint.ID,
EndpointName: endpoint.Name,
RoleID: access.Role.ID,
RoleName: access.Role.Name,
RolePriority: access.Role.Priority,
AccessLocation: AccessLocationEnvironment,
}
if access.Source.GroupID != 0 {
entry.GroupID = access.Source.GroupID
entry.AccessLocation = AccessLocationEnvironmentGroup
if g, ok := groupsByID[access.Source.GroupID]; ok {
entry.GroupName = g.Name
}
}
if access.Source.TeamID != 0 {
entry.TeamID = access.Source.TeamID
if t, ok := teamsByID[access.Source.TeamID]; ok {
entry.TeamName = t.Name
}
}
return entry
}
type EffectiveAccessEntry struct {
EndpointID portainer.EndpointID `json:"endpointId"`
EndpointName string `json:"endpointName"`
RoleID portainer.RoleID `json:"roleId"`
RoleName string `json:"roleName"`
RolePriority int `json:"rolePriority"`
GroupID portainer.EndpointGroupID `json:"groupId,omitempty"`
GroupName string `json:"groupName,omitempty"`
TeamID portainer.TeamID `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
AccessLocation AccessLocation `json:"accessLocation"`
}

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