Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b8d0db0be | |||
| c52767fb04 | |||
| 8e39a16172 | |||
| e964be75db | |||
| 6776b01ac8 | |||
| b96031965a | |||
| b2a2e5c222 | |||
| 27285a94ac | |||
| b3f01973ec | |||
| 17ffd62480 | |||
| 86f6aba362 | |||
| 718e11ccd0 | |||
| e68b0e80f1 | |||
| 9a14f2acb7 | |||
| 01ff1486e0 | |||
| b91f77a554 |
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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==
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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, ®istry)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "", ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "", ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user