Compare commits
103 Commits
release/2.
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cc29fa3c | ||
|
|
5e2eb667b4 | ||
|
|
1f9c9b082f | ||
|
|
722c1875af | ||
|
|
68471d0225 | ||
|
|
a6900545b0 | ||
|
|
808ceba848 | ||
|
|
a796a03a15 | ||
|
|
5a5dc67209 | ||
|
|
69ae54b523 | ||
|
|
b405227d51 | ||
|
|
44be39a9a4 | ||
|
|
5de0cc199c | ||
|
|
0c9e408eda | ||
|
|
1007f1f740 | ||
|
|
774e3d5948 | ||
|
|
4d866d066a | ||
|
|
da6544e981 | ||
|
|
3af9a7646d | ||
|
|
0e2cf82e3e | ||
|
|
97e69b9887 | ||
|
|
692f91263b | ||
|
|
8b61d8a9d2 | ||
|
|
25d51f9515 | ||
|
|
20b971dc1f | ||
|
|
7a76d749e3 | ||
|
|
123afd9462 | ||
|
|
ad83478b77 | ||
|
|
2ad0a65613 | ||
|
|
1f5762b8c8 | ||
|
|
0370b09ad0 | ||
|
|
5869a8948d | ||
|
|
56a840e207 | ||
|
|
a01dd005fd | ||
|
|
9ad6c16d43 | ||
|
|
9cc3e16db9 | ||
|
|
d02bcdba29 | ||
|
|
c708fe577c | ||
|
|
c92161bb22 | ||
|
|
138aa13fdc | ||
|
|
988a795def | ||
|
|
3f7a3053ff | ||
|
|
0c8c6865be | ||
|
|
2bbcae39b6 | ||
|
|
caf6b2aa0c | ||
|
|
a00f05fe32 | ||
|
|
9fcac1ab4f | ||
|
|
ae24ad4693 | ||
|
|
0f721b60a9 | ||
|
|
e8b49f53e1 | ||
|
|
27531a802b | ||
|
|
4bbf0ce0c0 | ||
|
|
e0c22ea3eb | ||
|
|
b7eb2ba068 | ||
|
|
affdb69568 | ||
|
|
763b7da65c | ||
|
|
42e9165347 | ||
|
|
16dd08a359 | ||
|
|
936494615c | ||
|
|
5769c0b98e | ||
|
|
b7e1caa8c6 | ||
|
|
e02ae6b2fb | ||
|
|
d9f131a2c5 | ||
|
|
ad1f7dbaa5 | ||
|
|
aa6da0f6d3 | ||
|
|
376071e408 | ||
|
|
d3544fb9b3 | ||
|
|
c8497b3944 | ||
|
|
5aa92b8413 | ||
|
|
bccb6694d4 | ||
|
|
506a11c658 | ||
|
|
bdc315a59d | ||
|
|
ec7d3bddfc | ||
|
|
762c1ccf28 | ||
|
|
8e44c8fa06 | ||
|
|
20db102327 | ||
|
|
1643cb8165 | ||
|
|
49e623dfeb | ||
|
|
a1208974ac | ||
|
|
d611087513 | ||
|
|
ac7cb2ee19 | ||
|
|
f866572cbf | ||
|
|
4c6942f60b | ||
|
|
d939897524 | ||
|
|
66c5589fd7 | ||
|
|
379b1d611b | ||
|
|
f16221f385 | ||
|
|
9b82560270 | ||
|
|
7271af03e6 | ||
|
|
4d564bbce2 | ||
|
|
d7afdf214b | ||
|
|
18e445ea02 | ||
|
|
cb70c705a3 | ||
|
|
9a77eb9872 | ||
|
|
ec82f646a0 | ||
|
|
2f0e384240 | ||
|
|
19a1426869 | ||
|
|
cc5cd8db6b | ||
|
|
e384e2edda | ||
|
|
dca044873f | ||
|
|
8aadddcc68 | ||
|
|
2e95229c51 | ||
|
|
8a1d02c23f |
@@ -139,15 +139,19 @@ overrides:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
plugins:
|
||||
- '@vitest'
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
- 'plugin:@vitest/legacy-recommended'
|
||||
env:
|
||||
'vitest/env': true
|
||||
'@vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'@vitest/no-conditional-expect': warn
|
||||
'max-classes-per-file': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
@@ -155,3 +159,4 @@ overrides:
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'storybook/no-renderer-packages': off
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,10 +94,14 @@ 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.39.0'
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
@@ -139,8 +143,6 @@ body:
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '2.21.2'
|
||||
- '2.21.1'
|
||||
- '2.21.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ linters:
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
|
||||
@@ -54,8 +54,28 @@ linters:
|
||||
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
||||
- pkg: github.com/cosi-project/runtime
|
||||
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
|
||||
- pkg: gopkg.in/yaml.v2
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: gopkg.in/yaml.v3
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: github.com/golang-jwt/jwt/v4
|
||||
desc: use github.com/golang-jwt/jwt/v5 instead
|
||||
- pkg: github.com/mitchellh/mapstructure
|
||||
desc: use github.com/go-viper/mapstructure/v2 instead
|
||||
- pkg: gopkg.in/alecthomas/kingpin.v2
|
||||
desc: use github.com/alecthomas/kingpin/v2 instead
|
||||
- pkg: github.com/jcmturner/gokrb5$
|
||||
desc: use github.com/jcmturner/gokrb5/v8 instead
|
||||
- pkg: github.com/gofrs/uuid
|
||||
desc: use github.com/google/uuid
|
||||
- pkg: github.com/Masterminds/semver$
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/blang/semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/coreos/go-semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/hashicorp/go-version
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^tls\.Config$
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
@@ -9,20 +9,38 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
@@ -21,31 +21,30 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
const preview: Preview = {
|
||||
decorators: (Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
];
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
export default preview;
|
||||
|
||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
see also:
|
||||
|
||||
- docs/guidelines/server-architecture.md
|
||||
- docs/guidelines/go-conventions.md
|
||||
- docs/guidelines/typescript-conventions.md
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.25.7 (for backend)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
make build # Build both client and server
|
||||
make build-client # Build React/AngularJS frontend
|
||||
make build-server # Build Go binary
|
||||
make build-image # Build Docker image
|
||||
|
||||
# Development
|
||||
make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
pnpm run dev # Webpack dev server
|
||||
pnpm run build # Build frontend with webpack
|
||||
pnpm run test # Run frontend tests
|
||||
|
||||
# Testing
|
||||
make test # All tests (backend + frontend)
|
||||
make test-server # Backend tests only
|
||||
make lint # Lint all code
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Development Servers
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
@@ -21,7 +21,11 @@ The Portainer team takes the security of our products seriously. If you believe
|
||||
|
||||
### Disclosure Process
|
||||
|
||||
1. **Report**: Email your findings to security@portainer.io.
|
||||
1. **Report**: You can report in one of two ways:
|
||||
|
||||
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
|
||||
|
||||
- **Email**: Send your findings to security@portainer.io.
|
||||
|
||||
2. **Details**: To help us verify the issue, please include:
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -108,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
|
||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
|
||||
@@ -55,7 +55,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
}
|
||||
|
||||
if isNew {
|
||||
instanceId, err := uuid.NewV4()
|
||||
instanceId, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS {
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -29,7 +29,7 @@ func secretToEncryptionKey(passphrase string) []byte {
|
||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
uuid := uuid.Must(uuid.NewV4())
|
||||
uuid := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
object any
|
||||
|
||||
@@ -119,6 +119,19 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||
func (service *Service) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||
var endpoints []portainer.Endpoint
|
||||
var err error
|
||||
|
||||
err = service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
endpoints, err = service.Tx(tx).ReadAll(predicates...)
|
||||
return err
|
||||
})
|
||||
|
||||
return endpoints, err
|
||||
}
|
||||
|
||||
// EndpointIDByEdgeID returns the EndpointID from the given EdgeID using an in-memory index
|
||||
func (service *Service) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||
service.mu.RLock()
|
||||
|
||||
@@ -89,6 +89,11 @@ func (service ServiceTx) Endpoints() ([]portainer.Endpoint, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// ReadAll retrieves all the elements that satisfy all the provided predicates.
|
||||
func (service ServiceTx) ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error) {
|
||||
return dataservices.BaseDataServiceTx[portainer.Endpoint, portainer.EndpointID]{Bucket: BucketName, Connection: service.service.connection, Tx: service.tx}.ReadAll(predicates...)
|
||||
}
|
||||
|
||||
func (service ServiceTx) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) {
|
||||
log.Error().Str("func", "EndpointIDByEdgeID").Msg("cannot be called inside a transaction")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
ErrDBImportFailed = errors.New("importing backup failed")
|
||||
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
||||
)
|
||||
|
||||
@@ -102,6 +102,9 @@ type (
|
||||
|
||||
// EndpointService represents a service for managing environment(endpoint) data
|
||||
EndpointService interface {
|
||||
// partial dataservices.BaseCRUD[portainer.Endpoint, portainer.EndpointID]
|
||||
ReadAll(predicates ...func(endpoint portainer.Endpoint) bool) ([]portainer.Endpoint, error)
|
||||
|
||||
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
||||
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
|
||||
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
|
||||
|
||||
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
service: service,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// Settings retrieve the ssl settings object.
|
||||
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
31
api/dataservices/ssl/tx.go
Normal file
31
api/dataservices/ssl/tx.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
service *Service
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
|
||||
// Settings retrieve the settings object.
|
||||
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
err := service.tx.GetObject(BucketName, []byte(key), &settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings persists a Settings object.
|
||||
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||
return service.tx.UpdateObject(BucketName, []byte(key), settings)
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/boltdb"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateData(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
|
||||
|
||||
// In this particular instance we should log a fatal error
|
||||
if m.CurrentDBEdition() != portainer.PortainerCE {
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/version"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package postinit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -44,40 +46,65 @@ func NewPostInitMigrator(
|
||||
|
||||
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
|
||||
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error getting environments")
|
||||
return err
|
||||
}
|
||||
var environments []portainer.Endpoint
|
||||
|
||||
for _, environment := range environments {
|
||||
// edge environments will run after the server starts, in pending actions
|
||||
if endpoints.IsEdgeEndpoint(&environment) {
|
||||
// Skip edge environments that do not have direct connectivity
|
||||
if !endpoints.HasDirectConnectivity(&environment) {
|
||||
if err := postInitMigrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
if environments, err = tx.Endpoint().ReadAll(func(endpoint portainer.Endpoint) bool {
|
||||
return endpoints.HasDirectConnectivity(&endpoint)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to retrieve environments: %w", err)
|
||||
}
|
||||
|
||||
var pendingActions []portainer.PendingAction
|
||||
if pendingActions, err = tx.PendingActions().ReadAll(func(action portainer.PendingAction) bool {
|
||||
return action.Action == actions.PostInitMigrateEnvironment
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
// Sort for the binary search in createPostInitMigrationPendingAction()
|
||||
slices.SortFunc(pendingActions, func(a, b portainer.PendingAction) int {
|
||||
return cmp.Compare(a.EndpointID, b.EndpointID)
|
||||
})
|
||||
|
||||
for _, environment := range environments {
|
||||
if !endpoints.IsEdgeEndpoint(&environment) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Edge environments will run after the server starts, in pending actions
|
||||
log.Info().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("adding pending action 'PostInitMigrateEnvironment' for environment")
|
||||
|
||||
if err := postInitMigrator.createPostInitMigrationPendingAction(environment.ID); err != nil {
|
||||
if err := postInitMigrator.createPostInitMigrationPendingAction(tx, environment.ID, pendingActions); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating pending action for environment")
|
||||
}
|
||||
} else {
|
||||
// Non-edge environments will run before the server starts.
|
||||
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error running post-init migrations for non-edge environment")
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("error running post-init migrations")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
for _, environment := range environments {
|
||||
if endpoints.IsEdgeEndpoint(&environment) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-edge environments will run before the server starts.
|
||||
if err := postInitMigrator.MigrateEnvironment(&environment); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error running post-init migrations for non-edge environment")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -85,44 +112,47 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
|
||||
// try to create a post init migration pending action. If it already exists, do nothing
|
||||
// this function exists for readability, not reusability
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
|
||||
// pending actions must be passed in ascending order by endpoint ID
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(tx dataservices.DataStoreTx, environmentID portainer.EndpointID, pendingActions []portainer.PendingAction) error {
|
||||
action := portainer.PendingAction{
|
||||
EndpointID: environmentID,
|
||||
Action: actions.PostInitMigrateEnvironment,
|
||||
}
|
||||
|
||||
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
if _, found := slices.BinarySearchFunc(pendingActions, environmentID, func(e portainer.PendingAction, id portainer.EndpointID) int {
|
||||
return cmp.Compare(e.EndpointID, id)
|
||||
}); found {
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return postInitMigrator.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
// MigrateEnvironment runs migrations on a single environment
|
||||
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
|
||||
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
|
||||
log.Info().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("executing post init migration for environment")
|
||||
|
||||
switch {
|
||||
case endpointutils.IsKubernetesEndpoint(environment):
|
||||
// get the kubeclient for the environment, and skip all kube migrations if there's an error
|
||||
kubeclient, err := migrator.kubeFactory.GetPrivilegedKubeClient(environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating kubeclient for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
|
||||
// If one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
|
||||
if err := migrator.MigrateIngresses(*environment, kubeclient); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,12 +162,21 @@ func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endp
|
||||
// get the docker client for the environment, and skip all docker migrations if there's an error
|
||||
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error creating docker client for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
defer logs.CloseAndLogErr(dockerClient)
|
||||
|
||||
if err := migrator.MigrateGPUs(*environment, dockerClient); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error migrating GPUs for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -150,13 +189,20 @@ func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoin
|
||||
if !environment.PostInitMigrations.MigrateIngresses {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
|
||||
|
||||
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("migrating ingresses for environment")
|
||||
|
||||
if err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error migrating ingresses for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -166,29 +212,42 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
|
||||
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
environment, err := tx.Endpoint().Endpoint(e.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting environment %d", e.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(e.ID)).
|
||||
Msg("error getting environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Early exit if we do not need to migrate!
|
||||
if !environment.PostInitMigrations.MigrateGPUs {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
|
||||
|
||||
// get all containers
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(e.ID)).
|
||||
Msg("migrating GPUs for environment")
|
||||
|
||||
// Get all containers
|
||||
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("failed to list containers for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
|
||||
// Check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
|
||||
containersLoop:
|
||||
for _, container := range containers {
|
||||
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
|
||||
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to inspect container")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -202,10 +261,14 @@ func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient
|
||||
}
|
||||
}
|
||||
|
||||
// set the MigrateGPUs flag to false so we don't run this again
|
||||
// Set the MigrateGPUs flag to false so we don't run this again
|
||||
environment.PostInitMigrations.MigrateGPUs = false
|
||||
if err := tx.Endpoint().UpdateEndpoint(environment.ID, environment); err != nil {
|
||||
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environment.ID)).
|
||||
Msg("error updating EnableGPUManagement flag for environment")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Stack() dataservices.StackService {
|
||||
return tx.store.StackService.Tx(tx.tx)
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"allowDeviceMappingForRegularUsers": true,
|
||||
"allowHostNamespaceForRegularUsers": true,
|
||||
"allowPrivilegedModeForRegularUsers": true,
|
||||
"allowSecurityOptForRegularUsers": false,
|
||||
"allowStackManagementForRegularUsers": true,
|
||||
"allowSysctlSettingForRegularUsers": false,
|
||||
"allowVolumeBrowserForRegularUsers": false,
|
||||
@@ -613,7 +614,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -942,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/docker/docker/api/types"
|
||||
dockercontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
@@ -35,8 +36,10 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
@@ -44,8 +47,17 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
@@ -58,7 +61,6 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
expected := ContainerStats{}
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, expected, stats)
|
||||
assert.Equal(t, expected.Running, stats.Running)
|
||||
assert.Equal(t, expected.Stopped, stats.Stopped)
|
||||
assert.Equal(t, expected.Healthy, stats.Healthy)
|
||||
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
|
||||
@@ -77,6 +77,9 @@ type (
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig portainer.HelmConfig
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
|
||||
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
|
||||
operations := map[string]func(context.Context, []string) (string, error){
|
||||
"apply": client.ApplyDynamic,
|
||||
"delete": client.Delete,
|
||||
"delete": client.DeleteDynamic,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -812,7 +812,7 @@ func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) s
|
||||
|
||||
// GetTemporaryPath returns a temp folder
|
||||
func (service *Service) GetTemporaryPath() (string, error) {
|
||||
uid, err := uuid.NewV4()
|
||||
uid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -223,3 +223,15 @@ func TestIsInConfigDir(t *testing.T) {
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
|
||||
}
|
||||
|
||||
func TestShouldIncludeDir(t *testing.T) {
|
||||
f := func(dirEntry DirEntry, deviceName, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
actual := shouldIncludeDir(dirEntry, deviceName, configPath)
|
||||
assert.Equal(t, expect, actual)
|
||||
}
|
||||
|
||||
f(DirEntry{Name: "app/blue-app", IsFile: false}, "blue-app", "app", true)
|
||||
f(DirEntry{Name: "app/blue-app/values.yaml", IsFile: true}, "blue-app", "app", true)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -25,7 +26,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
|
||||
return "", errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
username, err := handler.OAuthService.Authenticate(code, settings)
|
||||
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
|
||||
}
|
||||
|
||||
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("OAuth authentication error")
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
|
||||
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
||||
}
|
||||
|
||||
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||
}
|
||||
|
||||
canEdit := userCanEditTemplate(customTemplate, securityContext)
|
||||
hasAccess := false
|
||||
|
||||
if resourceControl != nil {
|
||||
customTemplate.ResourceControl = resourceControl
|
||||
|
||||
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
|
||||
return m.TeamID
|
||||
})
|
||||
|
||||
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
|
||||
}
|
||||
|
||||
if canEdit || hasAccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
entryPath := customTemplate.EntryPoint
|
||||
|
||||
115
api/http/handler/customtemplates/customtemplate_file_test.go
Normal file
115
api/http/handler/customtemplates/customtemplate_file_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCustomTemplateFile(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
templateContent := "some template content"
|
||||
templateEntrypoint := "entrypoint"
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
|
||||
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||
2: portainer.AccessPolicy{RoleID: 0},
|
||||
3: portainer.AccessPolicy{RoleID: 0},
|
||||
}}))
|
||||
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
|
||||
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
|
||||
|
||||
// template 1
|
||||
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
// template 2
|
||||
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||
|
||||
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
|
||||
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
|
||||
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
|
||||
}))
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
|
||||
r = mux.SetURLVars(r, map[string]string{"id": templateID})
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
return rr, handler.customTemplateFile(rr, r)
|
||||
}
|
||||
|
||||
t.Run("unknown id should get not found error", func(t *testing.T) {
|
||||
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusNotFound, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin should access adminonly template", func(t *testing.T) {
|
||||
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access adminonly template", func(t *testing.T) {
|
||||
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("std should access template via direct user access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should access template via team access", func(t *testing.T) {
|
||||
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
|
||||
require.Nil(t, r)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
var res struct{ FileContent string }
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||
require.Equal(t, templateContent, res.FileContent)
|
||||
})
|
||||
|
||||
t.Run("std should not access template without access", func(t *testing.T) {
|
||||
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
|
||||
var customTemplate *portainer.CustomTemplate
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
|
||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||
require.NotNil(t, ds)
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
|
||||
return nil
|
||||
}))
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||
|
||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
_, err := tx.EdgeGroup().Read(ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EdgeGroup().Exists(ID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
|
||||
|
||||
if len(payload.EdgeGroups) > 0 {
|
||||
for _, edgeGroupID := range payload.EdgeGroups {
|
||||
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
|
||||
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
|
||||
return dserrors.ErrObjectNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
|
||||
_, err := tx.EndpointGroup().Read(endpointGroupID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
|
||||
// Environment(Endpoint) group name
|
||||
Name string `example:"my-environment-group"`
|
||||
// Environment(Endpoint) group description
|
||||
Description string `example:"description"`
|
||||
Description *string `example:"description"`
|
||||
// List of environment(endpoint) identifiers that will be part of this group
|
||||
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
|
||||
// List of tag identifiers associated to the environment(endpoint) group
|
||||
TagIDs []portainer.TagID `example:"3,4"`
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
endpointGroup.Name = payload.Name
|
||||
}
|
||||
|
||||
if payload.Description != "" {
|
||||
endpointGroup.Description = payload.Description
|
||||
if payload.Description != nil {
|
||||
endpointGroup.Description = *payload.Description
|
||||
}
|
||||
|
||||
tagsChanged := false
|
||||
@@ -147,11 +149,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
|
||||
// Update flag with endpoint and continue
|
||||
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
}
|
||||
}(endpoint.ID, endpointGroup.ID)
|
||||
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
|
||||
}
|
||||
|
||||
if tagsChanged {
|
||||
// Handle associated endpoints updates
|
||||
endpointsChanged := false
|
||||
if payload.AssociatedEndpoints != nil {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
// Build a set of the new endpoint IDs for quick lookup
|
||||
newEndpointSet := make(map[portainer.EndpointID]bool)
|
||||
for _, id := range payload.AssociatedEndpoints {
|
||||
newEndpointSet[id] = true
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
wasInGroup := endpoint.GroupID == endpointGroup.ID
|
||||
shouldBeInGroup := newEndpointSet[endpoint.ID]
|
||||
|
||||
if wasInGroup && !shouldBeInGroup {
|
||||
// Remove from group (move to Unassigned)
|
||||
endpoint.GroupID = portainer.EndpointGroupID(1)
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
} else if !wasInGroup && shouldBeInGroup {
|
||||
// Add to group
|
||||
endpoint.GroupID = endpointGroup.ID
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
|
||||
if tagsChanged && !endpointsChanged {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type endpointCreatePayload struct {
|
||||
@@ -405,7 +405,7 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
|
||||
}
|
||||
|
||||
if settings.EnforceEdgeID {
|
||||
edgeID, err := uuid.NewV4()
|
||||
edgeID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Cannot generate the Edge ID", err)
|
||||
}
|
||||
|
||||
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
|
||||
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
||||
|
||||
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
|
||||
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to update user authorizations")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
|
||||
}
|
||||
@@ -179,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
err = tx.Tag().Update(tagID, tag)
|
||||
}
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
log.Warn().Err(err).Msg("Unable to find tag inside the database")
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
|
||||
@@ -227,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
|
||||
if endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
|
||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const (
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||
|
||||
@@ -26,6 +26,8 @@ type endpointSettingsUpdatePayload struct {
|
||||
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
|
||||
@@ -111,6 +113,12 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
|
||||
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
|
||||
}
|
||||
|
||||
if payload.AllowSecurityOptForRegularUsers != nil {
|
||||
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
|
||||
}
|
||||
|
||||
endpoint.SecuritySettings = securitySettings
|
||||
|
||||
if payload.EnableGPUManagement != nil {
|
||||
endpoint.EnableGPUManagement = *payload.EnableGPUManagement
|
||||
}
|
||||
@@ -119,8 +127,6 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
|
||||
endpoint.Gpus = payload.Gpus
|
||||
}
|
||||
|
||||
endpoint.SecuritySettings = securitySettings
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Failed persisting environment in database", err)
|
||||
|
||||
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
|
||||
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
|
||||
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
excludeGroupIds []portainer.EndpointGroupID
|
||||
edgeGroupIds []portainer.EdgeGroupID
|
||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||
}
|
||||
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
tagIds: tagIDs,
|
||||
endpointIds: endpointIDs,
|
||||
excludeIds: excludeIDs,
|
||||
excludeGroupIds: excludeGroupIDs,
|
||||
tagsPartialMatch: tagsPartialMatch,
|
||||
groupIds: groupIDs,
|
||||
status: status,
|
||||
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.excludeGroupIds) > 0 {
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.groupIds) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
|
||||
runTests(tests, t, handler, environments)
|
||||
}
|
||||
|
||||
func Test_Filter_excludeGroupIDs(t *testing.T) {
|
||||
groupA := portainer.EndpointGroupID(10)
|
||||
groupB := portainer.EndpointGroupID(20)
|
||||
groupC := portainer.EndpointGroupID(30)
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
|
||||
}
|
||||
|
||||
handler := setupFilterTest(t, endpoints)
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
title: "should exclude endpoints in groupA",
|
||||
expected: []portainer.EndpointID{3, 4, 5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should exclude endpoints in groupA and groupB",
|
||||
expected: []portainer.EndpointID{5},
|
||||
query: EnvironmentsQuery{
|
||||
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "should return all endpoints when excludeGroupIds is empty",
|
||||
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
|
||||
query: EnvironmentsQuery{},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
||||
n := 10000
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.37.0
|
||||
// @version 2.39.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/kubernetes/validation"
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type installChartPayload struct {
|
||||
@@ -95,7 +94,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
|
||||
return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", "))
|
||||
}
|
||||
|
||||
if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 {
|
||||
if err := validation.IsDNS1123Subdomain(p.Name); err != nil {
|
||||
return errChartNameInvalid
|
||||
}
|
||||
|
||||
@@ -108,6 +107,23 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
return nil, httperr.Err
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
|
||||
var username string
|
||||
if err := handler.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err := tx.User().Read(tokenData.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to load user information from the database")
|
||||
}
|
||||
username = user.Username
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
installOpts := options.InstallOptions{
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
@@ -117,6 +133,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
Atomic: p.Atomic,
|
||||
DryRun: dryRun,
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
HelmAppLabels: kubernetes.GetHelmAppLabels(p.Name, username),
|
||||
}
|
||||
|
||||
if p.Values != "" {
|
||||
@@ -147,105 +164,5 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !installOpts.DryRun {
|
||||
manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// applyPortainerLabelsToHelmAppManifest will patch all the resources deployed in the helm release manifest
|
||||
// with portainer specific labels. This is to mark the resources as managed by portainer - hence the helm apps
|
||||
// wont appear external in the portainer UI.
|
||||
func (handler *Handler) applyPortainerLabelsToHelmAppManifest(r *http.Request, installOpts options.InstallOptions, manifest string) ([]byte, error) {
|
||||
// Patch helm release by adding with portainer labels to all deployed resources
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
|
||||
user, err := handler.dataStore.User().Read(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to load user information from the database")
|
||||
}
|
||||
|
||||
appLabels := kubernetes.GetHelmAppLabels(installOpts.Name, user.Username)
|
||||
|
||||
labeledManifest, err := kubernetes.AddAppLabels([]byte(manifest), appLabels)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to label helm release manifest")
|
||||
}
|
||||
|
||||
return labeledManifest, nil
|
||||
}
|
||||
|
||||
// updateHelmAppManifest will update the resources of helm release manifest with portainer labels using kubectl.
|
||||
// The resources of the manifest will be updated in parallel and individuallly since resources of a chart
|
||||
// can be deployed to different namespaces.
|
||||
// NOTE: These updates will need to be re-applied when upgrading the helm release
|
||||
func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte, namespace string) error {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to find an endpoint on request context")
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to retrieve user details from authentication token")
|
||||
}
|
||||
|
||||
// Extract list of YAML resources from Helm manifest
|
||||
yamlResources, err := kubernetes.ExtractDocuments(manifest, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to extract documents from helm release manifest")
|
||||
}
|
||||
|
||||
// Deploy individual resources in parallel
|
||||
g := new(errgroup.Group)
|
||||
for _, resource := range yamlResources {
|
||||
g.Go(func() error {
|
||||
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
||||
}
|
||||
defer func() {
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close tmp helm manifest file")
|
||||
}
|
||||
|
||||
if err := os.Remove(tmpfile.Name()); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to remove tmp helm manifest file")
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpfile.Write(resource); err != nil {
|
||||
return errors.Wrap(err, "failed to write a tmp helm manifest file")
|
||||
}
|
||||
|
||||
// get resource namespace, fallback to provided namespace if not explicit on resource
|
||||
resourceNamespace, err := kubernetes.GetNamespace(resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resourceNamespace == "" {
|
||||
resourceNamespace = namespace
|
||||
}
|
||||
|
||||
_, err = handler.kubernetesDeployer.Deploy(tokenData.ID, endpoint, []string{tmpfile.Name()}, resourceNamespace)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return errors.Wrap(err, "unable to patch helm release using kubectl")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
|
||||
@@ -2,8 +2,10 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -31,33 +33,23 @@ import (
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
|
||||
// @router /kubernetes/{id}/ingresscontrollers [get]
|
||||
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
|
||||
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
|
||||
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
|
||||
}
|
||||
|
||||
// Get endpoint from context (may have policies applied in-memory)
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
Type: "custom",
|
||||
})
|
||||
}
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses = []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
controllers = allowedControllers
|
||||
}
|
||||
|
||||
return response.JSON(w, controllers)
|
||||
}
|
||||
|
||||
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
|
||||
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace from request", err)
|
||||
}
|
||||
|
||||
currentControllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
|
||||
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
currentControllers = append(currentControllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
})
|
||||
}
|
||||
|
||||
kubernetesConfig := endpoint.Kubernetes.Configuration
|
||||
existingClasses := kubernetesConfig.IngressClasses
|
||||
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
|
||||
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
|
||||
controllers := models.K8sIngressControllers{}
|
||||
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers."
|
||||
// @router /kubernetes/{id}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
|
||||
return httperror.BadRequest("Unable to retrieve environment", err)
|
||||
}
|
||||
|
||||
payload := models.K8sIngressControllers{}
|
||||
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use policy-applied endpoint for this check since it affects the response.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
})
|
||||
}
|
||||
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.NotFound("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
|
||||
return httperror.BadRequest("Unable to decode and validate the request payload", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
PayloadLoop:
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
for _, ns := range updatedClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
break
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
@@ -51,47 +52,52 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return httperror.InternalServerError("Unable to remove the registry from the database", err)
|
||||
}
|
||||
|
||||
handler.deleteKubernetesSecrets(registry)
|
||||
handler.deleteKubernetesSecrets(handler.DataStore, registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
|
||||
for endpointId, access := range registry.RegistryAccesses {
|
||||
if access.Namespaces != nil {
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
if access.Namespaces == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
if len(failedNamespaces) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedNamespaces) > 0 {
|
||||
if err := handler.PendingActionsService.Create(
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
if err := handler.PendingActionsService.Create(
|
||||
tx,
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
|
||||
// Make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
|
||||
@@ -192,28 +192,23 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
|
||||
// @router /stacks/create/swarm/repository [post]
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for name collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return stackExistsError(payload.Name)
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
return isUniqueStackName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
|
||||
_, err := tx.Stack().StackByWebhookID(webhookID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -16,7 +17,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
|
||||
}
|
||||
}
|
||||
|
||||
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
|
||||
return fmt.Errorf("failed to remove kubernetes resources: %q: %w", out, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported stack type: %v", stack.Type)
|
||||
@@ -315,7 +315,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID)
|
||||
|
||||
errors := make([]error, 0)
|
||||
var errs error
|
||||
// Delete all the stacks one by one
|
||||
for _, stack := range stacksToDelete {
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID)
|
||||
@@ -328,27 +328,27 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
err = handler.deleteStack(securityContext.UserID, &stack, endpoint)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID)
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.DataStore.Stack().Delete(stack.ID); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
if errs != nil {
|
||||
return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
|
||||
(stack.AutoUpdate == nil ||
|
||||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
|
||||
webhook, err := uuid.NewV4()
|
||||
webhook, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// @id WebhookInvoke
|
||||
@@ -56,7 +56,7 @@ func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, er
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
uid, err := uuid.FromString(webhookID)
|
||||
uid, err := uuid.Parse(webhookID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -52,7 +52,7 @@ func TestHandler_webhookInvoke(t *testing.T) {
|
||||
}
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
require.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -109,5 +109,5 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentVersionSemver.LessThan(*latestVersionSemver)
|
||||
return currentVersionSemver.LessThan(latestVersionSemver)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type webhookCreatePayload struct {
|
||||
@@ -86,7 +86,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
|
||||
}
|
||||
}
|
||||
|
||||
token, err := uuid.NewV4()
|
||||
token, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Error creating unique token", err)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var (
|
||||
ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace")
|
||||
ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping")
|
||||
ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings")
|
||||
ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings")
|
||||
ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities")
|
||||
ErrBindMountsForbidden = errors.New("forbidden to use bind mounts")
|
||||
)
|
||||
@@ -90,7 +91,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
|
||||
// containerInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the container based on resource control and either rewrite an access denied response or a decorated container.
|
||||
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
//ContainerInspect response is a JSON object
|
||||
// ContainerInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
@@ -116,6 +117,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str
|
||||
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
|
||||
if containerConfigObject != nil {
|
||||
containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels")
|
||||
|
||||
return containerLabelsObject
|
||||
}
|
||||
|
||||
@@ -170,13 +172,14 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
|
||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
type PartialContainer struct {
|
||||
HostConfig struct {
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
@@ -226,6 +229,10 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
return forbiddenResponse, ErrSysCtlSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
|
||||
return forbiddenResponse, ErrSecurityOptSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
|
||||
return nil, ErrContainerCapabilitiesForbidden
|
||||
}
|
||||
|
||||
@@ -747,7 +747,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, response.StatusCode)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
|
||||
@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
|
||||
}
|
||||
}
|
||||
|
||||
return service.UpdateUsersAuthorizationsTx(tx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
|
||||
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserAuthorizations will update the authorizations for the provided userid
|
||||
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
err := service.updateUserAuthorizations(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
|
||||
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
|
||||
//
|
||||
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
|
||||
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
|
||||
func (service *Service) UpdateUsersAuthorizations() error {
|
||||
return service.UpdateUsersAuthorizationsTx(service.dataStore)
|
||||
}
|
||||
|
||||
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
|
||||
users, err := tx.User().ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
err := service.updateUserAuthorizations(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointAuthorizations, err := service.getAuthorizations(tx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.EndpointAuthorizations = endpointAuthorizations
|
||||
|
||||
return tx.User().Update(userID, user)
|
||||
}
|
||||
|
||||
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
|
||||
endpointAuthorizations := portainer.EndpointAuthorizations{}
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointGroups, err := tx.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
roles, err := tx.Role().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
|
||||
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
|
||||
endpointAuthorizations := make(portainer.EndpointAuthorizations)
|
||||
|
||||
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
|
||||
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
|
||||
for _, endpointGroup := range endpointGroups {
|
||||
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
|
||||
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return endpointAuthorizations
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := endpoint.UserAccessPolicies[user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
|
||||
var associatedRoles []portainer.Role
|
||||
|
||||
for _, id := range roleIdentifiers {
|
||||
for _, role := range roles {
|
||||
if role.ID == id {
|
||||
associatedRoles = append(associatedRoles, role)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizations portainer.Authorizations
|
||||
highestPriority := 0
|
||||
for _, role := range associatedRoles {
|
||||
if role.Priority > highestPriority {
|
||||
highestPriority = role.Priority
|
||||
authorizations = role.Authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return authorizations
|
||||
}
|
||||
|
||||
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package errorlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Combine a slice of errors into a single error
|
||||
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
|
||||
func Combine(errorList []error) error {
|
||||
if len(errorList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errorMsg strings.Builder
|
||||
_, _ = errorMsg.WriteString("Multiple errors occurred:")
|
||||
for _, err := range errorList {
|
||||
_, _ = errorMsg.WriteString("\n" + err.Error())
|
||||
}
|
||||
|
||||
return errors.New(errorMsg.String())
|
||||
}
|
||||
@@ -262,6 +262,8 @@ func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOpti
|
||||
}
|
||||
|
||||
type stubEndpointService struct {
|
||||
dataservices.EndpointService
|
||||
|
||||
endpoints []portainer.Endpoint
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -185,7 +185,7 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
expiresAt = time.Now().Add(99 * year)
|
||||
}
|
||||
|
||||
uuid, err := uuid.NewV4()
|
||||
uuid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to generate the JWT ID: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -50,7 +49,7 @@ func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole {
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for _, name := range req {
|
||||
client := kcl.cli.RbacV1().ClusterRoles()
|
||||
|
||||
@@ -70,11 +69,11 @@ func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequest
|
||||
err = client.Delete(context.Background(), name, meta.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role")
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
func isSystemClusterRole(role *rbacv1.ClusterRole) bool {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -55,7 +54,7 @@ func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) model
|
||||
// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding
|
||||
// fails, the error is logged and we continue to delete the remaining cluster role bindings.
|
||||
func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
|
||||
for _, name := range reqs {
|
||||
client := kcl.cli.RbacV1().ClusterRoleBindings()
|
||||
@@ -76,11 +75,11 @@ func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindi
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding")
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool {
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -99,7 +99,7 @@ func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
|
||||
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
|
||||
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
|
||||
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range payload {
|
||||
for _, cronJobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().CronJobs(namespace)
|
||||
@@ -110,14 +110,14 @@ func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) e
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
||||
TTY: true,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
streamOpts := remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
}
|
||||
|
||||
// Try WebSocket executor first, fall back to SPDY if it fails
|
||||
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
|
||||
config,
|
||||
"GET", // WebSocket uses GET for the upgrade request
|
||||
req.URL().String(),
|
||||
channelProtocolList...,
|
||||
)
|
||||
if err != nil {
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
if err == nil {
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("context", "StartExecProcess").
|
||||
Msg("WebSocket exec failed, falling back to SPDY")
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
})
|
||||
// Fall back to SPDY executor
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err != nil {
|
||||
var exitError utilexec.ExitError
|
||||
if !errors.As(err, &exitError) {
|
||||
errChan <- errors.New("unable to start exec process")
|
||||
errChan <- fmt.Errorf("unable to start exec process: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -190,7 +190,7 @@ func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.Jo
|
||||
// DeleteJobs deletes the provided list of jobs
|
||||
// it returns an error if any of the jobs are not found or if there is an error deleting the jobs
|
||||
func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range payload {
|
||||
for _, jobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().Jobs(namespace)
|
||||
@@ -201,16 +201,16 @@ func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// getLatestJobCondition returns the latest condition of the job
|
||||
|
||||
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
|
||||
name: portainer-ctx
|
||||
current-context: portainer-ctx
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -131,7 +131,7 @@ func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool {
|
||||
// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
@@ -151,10 +151,10 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
}
|
||||
|
||||
if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -104,7 +104,7 @@ func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||
@@ -124,9 +124,9 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -92,7 +92,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
client := kcl.cli.CoreV1().ServiceAccounts(namespace)
|
||||
@@ -111,12 +111,12 @@ func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDelete
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user.
|
||||
|
||||
@@ -4,6 +4,7 @@ package validation
|
||||
// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,31 +17,31 @@ const DNS1123SubdomainMaxLength int = 253
|
||||
var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
|
||||
|
||||
// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
|
||||
func IsDNS1123Subdomain(value string) []string {
|
||||
var errs []string
|
||||
func IsDNS1123Subdomain(value string) error {
|
||||
var errs error
|
||||
if len(value) > DNS1123SubdomainMaxLength {
|
||||
errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
errs = errors.Join(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
}
|
||||
if !dns1123SubdomainRegexp.MatchString(value) {
|
||||
errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
errs = errors.Join(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// MaxLenError returns a string explanation of a "string too long" validation failure.
|
||||
func MaxLenError(length int) string {
|
||||
return fmt.Sprintf("must be no more than %d characters", length)
|
||||
func MaxLenError(length int) error {
|
||||
return fmt.Errorf("must be no more than %d characters", length)
|
||||
}
|
||||
|
||||
// RegexError returns a string explanation of a regex validation failure.
|
||||
func RegexError(fmt string, examples ...string) string {
|
||||
func RegexError(fmt string, examples ...string) error {
|
||||
var s strings.Builder
|
||||
|
||||
_, _ = s.WriteString("must match the regex ")
|
||||
_, _ = s.WriteString(fmt)
|
||||
|
||||
if len(examples) == 0 {
|
||||
return s.String()
|
||||
return errors.New(s.String())
|
||||
}
|
||||
|
||||
s.WriteString(" (e.g. ")
|
||||
@@ -56,5 +57,5 @@ func RegexError(fmt string, examples ...string) string {
|
||||
|
||||
_, _ = s.WriteRune(')')
|
||||
|
||||
return s.String()
|
||||
return errors.New(s.String())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
@@ -30,8 +30,11 @@ func NewService() Service {
|
||||
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
|
||||
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
|
||||
// from the resource server and matching it with the user identifier setting.
|
||||
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := GetOAuthToken(code, configuration)
|
||||
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := GetOAuthToken(ctx, code, configuration)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving oauth token")
|
||||
|
||||
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
log.Error().Err(err).Msg("failed parsing id_token")
|
||||
}
|
||||
|
||||
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
|
||||
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving resource")
|
||||
|
||||
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
|
||||
|
||||
config := buildConfig(configuration)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return config.Exchange(ctx, unescapedCode)
|
||||
}
|
||||
|
||||
@@ -87,9 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{
|
||||
SkipClaimsValidation: true,
|
||||
}
|
||||
jwtParser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
|
||||
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,9 +131,19 @@ func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
// Some OAuth providers (e.g. Cloudflare Access) return malformed Content-Type headers
|
||||
// (e.g. "application/json; charset=utf-8, application/json") that mime.ParseMediaType
|
||||
// cannot parse. We intentionally ignore that error: if parsing fails, content is empty,
|
||||
// the urlencoded branch is skipped, and json.Unmarshal below acts as the final validator.
|
||||
originalContentType := resp.Header.Get("Content-Type")
|
||||
content, _, err := mime.ParseMediaType(originalContentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("context", "OAuthResourceFetch").
|
||||
Str("original_content_type", originalContentType).
|
||||
Str("parsed_content_type", content).
|
||||
Msg("Failed to parse Content-Type header from resource endpoint, falling back to JSON")
|
||||
}
|
||||
|
||||
if content == "application/x-www-form-urlencoded" || content == "text/plain" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -18,14 +20,14 @@ func Test_getOAuthToken(t *testing.T) {
|
||||
|
||||
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
|
||||
code := ""
|
||||
if _, err := GetOAuthToken(code, config); err == nil {
|
||||
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
|
||||
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
|
||||
code := validCode
|
||||
token, err := GetOAuthToken(code, config)
|
||||
token, err := GetOAuthToken(t.Context(), code, config)
|
||||
|
||||
if token == nil || err != nil {
|
||||
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
|
||||
@@ -92,24 +94,57 @@ func Test_getResource(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_getResource_malformedContentType(t *testing.T) {
|
||||
body := `{"username":"test-oauth-user"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
}{
|
||||
{
|
||||
name: "duplicate mime types separated by comma",
|
||||
contentType: "application/json; charset=utf-8, application/json",
|
||||
},
|
||||
{
|
||||
name: "missing mime type with only parameters",
|
||||
contentType: "; charset=utf-8",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", tc.contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := GetResource(t.Context(), "any-token", srv.URL)
|
||||
require.NoError(t, err, "GetResource should succeed despite malformed Content-Type header")
|
||||
assert.Equal(t, "test-oauth-user", result["username"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Authenticate(t *testing.T) {
|
||||
code := "valid-code"
|
||||
authService := NewService()
|
||||
@@ -118,7 +153,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := authService.Authenticate(code, config); err == nil {
|
||||
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
|
||||
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
|
||||
}
|
||||
})
|
||||
@@ -128,7 +163,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, config)
|
||||
defer srv.Close()
|
||||
|
||||
username, err := authService.Authenticate(code, config)
|
||||
username, err := authService.Authenticate(t.Context(), code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -20,38 +21,34 @@ type PendingActionsService struct {
|
||||
|
||||
var handlers = make(map[string]portainer.PendingActionHandler)
|
||||
|
||||
func NewService(
|
||||
dataStore dataservices.DataStore,
|
||||
kubeFactory *kubecli.ClientFactory,
|
||||
) *PendingActionsService {
|
||||
return &PendingActionsService{
|
||||
dataStore: dataStore,
|
||||
kubeFactory: kubeFactory,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
|
||||
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
|
||||
handlers[name] = handler
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
|
||||
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
|
||||
// Check if this pendingAction already exists
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if len(pendingActions) > 0 {
|
||||
// Same endpoint, same action and data, don't create a repeat
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
|
||||
reflect.DeepEqual(dba.ActionData, action.ActionData) {
|
||||
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
|
||||
return nil
|
||||
}
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return service.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
|
||||
@@ -65,7 +62,8 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
|
||||
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,48 +84,55 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
// creating a kube client and performing a simple operation
|
||||
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("failed to create Kubernetes client for environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = client.ServerVersion(); err != nil {
|
||||
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("endpoint_name", endpoint.Name).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("environment is not up")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == environmentID
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to read pending actions: %v", err)
|
||||
log.Warn().Err(err).Msg("failed to read pending actions")
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
|
||||
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
|
||||
}
|
||||
|
||||
for i, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == environmentID {
|
||||
if i == 0 {
|
||||
// We have at least 1 pending action for this environment
|
||||
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
|
||||
}
|
||||
for _, pendingAction := range pendingActions {
|
||||
log.Debug().
|
||||
Int("pending_action_id", int(pendingAction.ID)).
|
||||
Str("action", pendingAction.Action).
|
||||
Msg("executing pending action")
|
||||
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to execute pending action")
|
||||
|
||||
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
|
||||
err := service.executePendingAction(pendingAction, endpoint)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to execute pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to delete pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to delete pending action")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +145,8 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
handler, ok := handlers[pendingAction.Action]
|
||||
if !ok {
|
||||
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
|
||||
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,18 @@ type (
|
||||
CreatedBy string `example:"admin"`
|
||||
}
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig struct {
|
||||
// Path to a Helm chart folder for Helm git deployments
|
||||
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
|
||||
// Array of paths to Helm values YAML files for Helm git deployments
|
||||
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
|
||||
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
|
||||
Atomic bool `json:"Atomic" example:"true"`
|
||||
// Timeout for Helm operations (equivalent to helm --timeout flag)
|
||||
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
|
||||
}
|
||||
|
||||
EdgeStackStatusForEnv struct {
|
||||
EndpointID EndpointID
|
||||
Status []EdgeStackDeploymentStatus
|
||||
@@ -544,11 +556,16 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartStatus struct {
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// EnvironmentID is the endpoint this status belongs to.
|
||||
// Stored so that ReadAll can group statuses by endpoint without parsing keys.
|
||||
EnvironmentID EndpointID `json:"environmentID,omitempty"`
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// Unix timestamp
|
||||
LastAttemptTime int64 `json:"lastAttemptTime"`
|
||||
}
|
||||
|
||||
ImageBundle struct {
|
||||
@@ -557,7 +574,7 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary
|
||||
PolicyChartSummary `mapstructure:",squash"`
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
@@ -584,7 +601,7 @@ type (
|
||||
|
||||
// RestoreSettings contains instructions for restoring environment-level settings
|
||||
RestoreSettings struct {
|
||||
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
|
||||
Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest
|
||||
}
|
||||
|
||||
// RestoreSettingsBundle maps restore type to restoration instructions
|
||||
@@ -628,6 +645,8 @@ type (
|
||||
AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
}
|
||||
@@ -1815,7 +1834,7 @@ type (
|
||||
|
||||
// OAuthService represents a service used to authenticate users using OAuth
|
||||
OAuthService interface {
|
||||
Authenticate(code string, configuration *OAuthSettings) (string, error)
|
||||
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
|
||||
}
|
||||
|
||||
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
|
||||
@@ -1855,9 +1874,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.37.0"
|
||||
APIVersion = "2.39.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1911,6 +1930,8 @@ const (
|
||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
|
||||
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
|
||||
// FeatureFlagEnvVar is the environment variable used to set the list of enabled feature flags
|
||||
FeatureFlagEnvVar = "FEATURE_FLAG"
|
||||
// LicenseServerBaseURL represents the base URL of the API used to validate
|
||||
// an extension license.
|
||||
LicenseServerBaseURL = "https://api.portainer.io"
|
||||
@@ -2431,14 +2452,15 @@ const (
|
||||
|
||||
const (
|
||||
// PolicyType constants
|
||||
RbacK8s PolicyType = "rbac-k8s"
|
||||
SecurityK8s PolicyType = "security-k8s"
|
||||
SetupK8s PolicyType = "setup-k8s"
|
||||
RegistryK8s PolicyType = "registry-k8s"
|
||||
RbacDocker PolicyType = "rbac-docker"
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
RbacK8s PolicyType = "rbac-k8s"
|
||||
SecurityK8s PolicyType = "security-k8s"
|
||||
SetupK8s PolicyType = "setup-k8s"
|
||||
RegistryK8s PolicyType = "registry-k8s"
|
||||
RbacDocker PolicyType = "rbac-docker"
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
ChangeConfirmation PolicyType = "change-confirmation"
|
||||
)
|
||||
|
||||
type HelmInstallStatus string
|
||||
@@ -2458,6 +2480,7 @@ func DefaultEndpointSecuritySettings() EndpointSecuritySettings {
|
||||
AllowHostNamespaceForRegularUsers: false,
|
||||
AllowPrivilegedModeForRegularUsers: false,
|
||||
AllowSysctlSettingForRegularUsers: false,
|
||||
AllowSecurityOptForRegularUsers: false,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
|
||||
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
ComposeOptions: options,
|
||||
ForceRecreate: forceRecreate,
|
||||
}); err != nil {
|
||||
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackFileContentBuilder struct {
|
||||
@@ -55,7 +55,7 @@ func (b *ComposeStackFileContentBuilder) SetFileContent(payload *StackPayload) F
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Compose file on disk: %w", err)
|
||||
return b
|
||||
}
|
||||
b.stack.ProjectPath = projectPath
|
||||
@@ -70,7 +70,7 @@ func (b *ComposeStackFileContentBuilder) Deploy(payload *StackPayload, endpoint
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackFileUploadBuilder struct {
|
||||
@@ -61,7 +60,7 @@ func (b *ComposeStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type ComposeStackGitBuilder struct {
|
||||
@@ -61,7 +60,7 @@ func (b *ComposeStackGitBuilder) Deploy(payload *StackPayload, endpoint *portain
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -18,38 +18,76 @@ func NewStackBuilderDirector(b any) *StackBuilderDirector {
|
||||
}
|
||||
}
|
||||
|
||||
// Build executes the stack build process based on the builder type. It returns the
|
||||
// created stack and any error encountered during the process.
|
||||
// The returned error is of type *httperror.HandlerError, which could be a BadRequest
|
||||
// or InternalServerError depending on the error encountered during the stack build process.
|
||||
func (d *StackBuilderDirector) Build(payload *StackPayload, endpoint *portainer.Endpoint) (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
var (
|
||||
stack *portainer.Stack
|
||||
err error
|
||||
)
|
||||
// To align with the flow of the actual service deployment tools, we save
|
||||
// the stack before the deployment. This allows us to track the stack
|
||||
// metadata and partially created resources.
|
||||
switch builder := d.builder.(type) {
|
||||
case GitMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetGitRepository(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SetAutoUpdate(payload).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via Git repository method", err)
|
||||
}
|
||||
|
||||
// Since AutoUpdate job for stack is created after a successful
|
||||
// deployment, we need to update the stack with the new generated job ID
|
||||
stack, err = builder.Deploy(payload, endpoint).
|
||||
SetAutoUpdate(payload).
|
||||
UpdateStack(stack)
|
||||
|
||||
case FileUploadMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetUploadedFile(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via File Upload method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
case FileContentMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetFileContent(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via File Content method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
case UrlMethodStackBuildProcess:
|
||||
return builder.SetGeneralInfo(payload, endpoint).
|
||||
stack, err = builder.SetGeneralInfo(payload, endpoint).
|
||||
SetUniqueInfo(payload).
|
||||
SetURL(payload).
|
||||
Deploy(payload, endpoint).
|
||||
SaveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack via URL method", err)
|
||||
}
|
||||
|
||||
builder.Deploy(payload, endpoint)
|
||||
err = builder.Error()
|
||||
|
||||
default:
|
||||
return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to deploy stack", err)
|
||||
}
|
||||
|
||||
return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter))
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type K8sStackFileContentBuilder struct {
|
||||
@@ -66,7 +66,7 @@ func (b *K8sStackFileContentBuilder) SetFileContent(payload *StackPayload) FileC
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Kubernetes Manifest file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Kubernetes Manifest file on disk: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (b *K8sStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *por
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.User, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type KubernetesStackGitBuilder struct {
|
||||
@@ -83,7 +83,7 @@ func (b *KubernetesStackGitBuilder) Deploy(payload *StackPayload, endpoint *port
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type KubernetesStackUrlBuilder struct {
|
||||
@@ -65,7 +65,7 @@ func (b *KubernetesStackUrlBuilder) SetURL(payload *StackPayload) UrlMethodStack
|
||||
|
||||
manifestContent, err := client.Get(payload.ManifestURL, 30)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to retrieve manifest from URL", err)
|
||||
b.err = fmt.Errorf("Unable to retrieve manifest from URL: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (b *KubernetesStackUrlBuilder) SetURL(payload *StackPayload) UrlMethodStack
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, manifestContent)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist Kubernetes manifest file on disk", err)
|
||||
b.err = fmt.Errorf("Unable to persist Kubernetes manifest file on disk: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (b *KubernetesStackUrlBuilder) Deploy(payload *StackPayload, endpoint *port
|
||||
|
||||
k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint)
|
||||
if err != nil {
|
||||
b.err = httperror.InternalServerError("failed to create temp kub deployment files", err)
|
||||
b.err = fmt.Errorf("failed to create temp kub deployment files: %w", err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -15,7 +16,7 @@ type StackBuilder struct {
|
||||
fileService portainer.FileService
|
||||
stackDeployer deployments.StackDeployer
|
||||
deploymentConfiger deployments.StackDeploymentConfiger
|
||||
err *httperror.HandlerError
|
||||
err error
|
||||
doCleanUp bool
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ func CreateStackBuilder(dataStore dataservices.DataStore, fileService portainer.
|
||||
}
|
||||
}
|
||||
|
||||
func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
func (b *StackBuilder) SaveStack() (*portainer.Stack, error) {
|
||||
defer func() { _ = b.cleanUp() }()
|
||||
|
||||
if b.hasError() {
|
||||
@@ -38,7 +39,7 @@ func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := tx.Stack().Create(b.stack); err != nil {
|
||||
b.err = httperror.InternalServerError("Unable to persist the stack inside the database", err)
|
||||
b.err = fmt.Errorf("Unable to persist the stack inside the database: %w", err)
|
||||
return b.err
|
||||
}
|
||||
|
||||
@@ -49,7 +50,11 @@ func (b *StackBuilder) SaveStack() (*portainer.Stack, *httperror.HandlerError) {
|
||||
|
||||
b.doCleanUp = false
|
||||
|
||||
return b.stack, b.err
|
||||
return b.stack, nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
func (b *StackBuilder) cleanUp() error {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
type FileContentMethodStackBuildProcess interface {
|
||||
@@ -15,11 +14,12 @@ type FileContentMethodStackBuildProcess interface {
|
||||
// Deploy stack based on the configuration
|
||||
Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess
|
||||
// Save the stack information to database
|
||||
SaveStack() (*portainer.Stack, *httperror.HandlerError)
|
||||
SaveStack() (*portainer.Stack, error)
|
||||
// Get response from HTTP request. Use if it is needed
|
||||
GetResponse() string
|
||||
// Process the file content
|
||||
SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess
|
||||
Error() error
|
||||
}
|
||||
|
||||
type FileContentMethodStackBuilder struct {
|
||||
@@ -50,9 +50,7 @@ func (b *FileContentMethodStackBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
if err := b.deploymentConfiger.Deploy(); err != nil {
|
||||
b.err = httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
b.err = b.deploymentConfiger.Deploy()
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -60,3 +58,7 @@ func (b *FileContentMethodStackBuilder) Deploy(payload *StackPayload, endpoint *
|
||||
func (b *FileContentMethodStackBuilder) GetResponse() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *FileContentMethodStackBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user