Compare commits

...

64 Commits

Author SHA1 Message Date
Du Feilong
c372909794 feat(containers): add securityOpt (#12408)
Co-authored-by: jerry-yuan <i@jerryzone.cn>
2026-03-09 10:41:00 +02:00
Steven Kang
988a795def fix(environment): collapsing More options breaking the style for podman - develop [R8S-874] (#1942) 2026-02-24 10:11:31 +13:00
Oscar Zhou
3f7a3053ff fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1940) 2026-02-24 08:41:42 +13:00
Oscar Zhou
0c8c6865be refactor(error): standardize multi errors handling [BE-12647] (#1933) 2026-02-23 09:40:01 +13:00
Chaim Lev-Ari
2bbcae39b6 feat: clean frontend test logs (#1894) 2026-02-22 09:42:49 +02:00
andres-portainer
caf6b2aa0c fix(policies): fixes for async edge R8S-661 (#1917) 2026-02-20 17:45:45 -03:00
Steven Kang
a00f05fe32 feat(environment): reorder options - develop [R8S-524] (#1822) 2026-02-20 14:58:01 +13:00
Chaim Lev-Ari
9fcac1ab4f chore(deps): upgrade axios [BE-12632] (#1864) 2026-02-19 15:38:08 +13:00
Josiah Clumont
ae24ad4693 Bump version to 2.39.0 for LTS (#1910) 2026-02-19 15:29:08 +13:00
RHCowan
0f721b60a9 fix(policy) Improve policy status performance [R8S-710] (#1878) 2026-02-19 15:24:14 +13:00
RHCowan
e8b49f53e1 fix(policy) fix policy group pagination issues [R8S-855] (#1898) 2026-02-19 13:29:01 +13:00
andres-portainer
27531a802b fix(fips): ensure custom registries cannot use HTTP without TLS BE-12511 (#1885)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-02-19 11:51:11 +13:00
Josiah Clumont
4bbf0ce0c0 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.39.0-LTS [R8S-818] (#1791) 2026-02-19 09:46:14 +13:00
Josiah Clumont
e0c22ea3eb fix(copy): Fixed an issue with the downgrade links [R8S-832] (#1907) 2026-02-19 09:38:04 +13:00
nickl-portainer
b7eb2ba068 fix(policies) convert all warnings to use PolicyOverrideAlert [R8S-837] (#1890) 2026-02-19 09:12:54 +13:00
Ali
affdb69568 fix(policies): show registry policy banner, and disable registry selector when policy applies [R8S-853] (#1891) 2026-02-19 08:37:02 +13:00
LP B
763b7da65c fix(api/docker): do not rewrite HTTP code in responses of create requests (#1854) 2026-02-18 19:26:29 +01:00
Chaim Lev-Ari
42e9165347 fix(stacks): generate webhook id for stacks (#1876) 2026-02-17 10:38:18 +02:00
Ali
16dd08a359 feat(widget): update widget tab styling product wide [r8s-850] (#1881) 2026-02-17 10:33:43 +13:00
Ali
936494615c fix(select): stop react-select overlapping with footer [R8S-794] (#1880) 2026-02-17 08:53:50 +13:00
andres-portainer
5769c0b98e fix(kubernetes): add missing returns BE-12582 (#1883) 2026-02-16 12:47:27 -03:00
andres-portainer
b7e1caa8c6 fix(boltdb): fix error handling BE-12582 (#1882) 2026-02-16 12:47:00 -03:00
andres-portainer
e02ae6b2fb fix(archive): prevent file traversal vulnerability BE-12582 (#1875) 2026-02-16 11:26:51 -03:00
testA113
d9f131a2c5 Revert "feat(widget): update widget tab styling product wide [r8s-850]"
This reverts commit d882c3b8fa4a03bf85b4e9fb1da729fabf903cb6.
2026-02-17 00:05:24 +13:00
testA113
ad1f7dbaa5 feat(widget): update widget tab styling product wide [r8s-850] 2026-02-17 00:01:07 +13:00
Devon Steenberg
aa6da0f6d3 feat(api-testing): add api testing framework [BE-12571] (#1824)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-02-16 09:35:06 +13:00
Oscar Zhou
376071e408 feat(edge/helm): add atomic and timeout options [BE-12481] (#1849) 2026-02-16 09:21:19 +13:00
Chaim Lev-Ari
d3544fb9b3 refactor(tests): mock ws server (#1853) 2026-02-15 08:58:24 +02:00
Chaim Lev-Ari
c8497b3944 chore(deps): upgrade html-loader (#1863) 2026-02-15 08:55:33 +02:00
andres-portainer
5aa92b8413 fix(webhooks): use transactions to check for webhook uniqueness BE-12613 (#1872) 2026-02-13 12:48:17 -03:00
Hannah Cooper
bccb6694d4 Update bug_report to include 2.38.1 (#1866) 2026-02-13 12:42:08 +13:00
Hannah Cooper
506a11c658 Update bug_report to include 2.33.7 (#1836) 2026-02-13 12:28:05 +13:00
Ali
bdc315a59d fix(helm): helm release not found error [r8s-842] (#1857) 2026-02-13 08:07:23 +13:00
andres-portainer
ec7d3bddfc fix(endpoints): fix transaction usage BE-12612 (#1838) 2026-02-11 12:34:46 -03:00
Chaim Lev-Ari
762c1ccf28 chore(deps): upgrade vitest and msw (#1852) 2026-02-11 17:14:04 +02:00
Malcolm Lockyer
8e44c8fa06 fix(webpack): fix common cfg after webpack-dev-server upgrade [r8s-841] (#1848) 2026-02-11 18:34:14 +13:00
Chaim Lev-Ari
20db102327 chore(deps): upgrade webpack (#1802) 2026-02-10 18:01:03 +02:00
Chaim Lev-Ari
1643cb8165 fix(environments): handle unix:// urls [BE-12610] (#1837)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-02-10 15:21:25 +02:00
Ali
49e623dfeb feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) 2026-02-10 23:44:44 +13:00
Steven Kang
a1208974ac fix(policy): pod security constraints - develop [R8S-808] (#1758)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-02-10 08:46:02 +09:00
Chaim Lev-Ari
d611087513 chore(deps): upgrade storybook 8 (#1811) 2026-02-08 09:59:08 +02:00
andres-portainer
ac7cb2ee19 fix(security): fix CVE-2025-68121 by upgrading Go compiler BE-12581 (#1813) 2026-02-06 13:17:12 -03:00
Oscar Zhou
f866572cbf fix(edge/helm): helm config section shows for other type [BE-12580] (#1808) 2026-02-06 09:13:06 +13:00
Chaim Lev-Ari
4c6942f60b fix(environments): update associated group [BE-12559] (#1760) 2026-02-05 18:48:02 +02:00
nickl-portainer
d939897524 feat(menu) add policies to environment settings submenu [R8S-806] (#1805) 2026-02-05 14:39:41 +13:00
nickl-portainer
66c5589fd7 fix(environment-list) resize kubeconfig download modal [R8S-814] (#1786)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-02-05 14:39:23 +13:00
Oscar Zhou
379b1d611b feat(edge/helm): support helm chart via git repository in edge stack [BE-12448] (#1649) 2026-02-05 13:22:31 +13:00
Chaim Lev-Ari
f16221f385 docs(claude): optimize memory files (#1777) 2026-02-05 04:28:36 +05:30
RHCowan
9b82560270 fix(policy) Fetch new status after policy update [R8S-711] (#1775) 2026-02-04 18:23:26 +13:00
Oscar Zhou
7271af03e6 fix(docker): dashboard api return 500 error [BE-12567] (#1784) 2026-02-04 08:32:01 +13:00
RHCowan
4d564bbce2 feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) 2026-02-03 12:32:22 +13:00
Oscar Zhou
d7afdf214b refactor(k8s): replace kubectl delete with delete api [BE-12560] (#1768) 2026-02-03 08:36:08 +13:00
Chaim Lev-Ari
18e445ea02 refactor(environments): migrate item view to react [BE-6632] (#1747) 2026-01-31 15:05:11 +07:00
nickl-portainer
cb70c705a3 fix(react): namespace selects sort alphabetically [R8S-765] (#1671) 2026-01-30 08:23:01 +13:00
Ali
9a77eb9872 chore(environment-groups): migrate environment groups to react [R8S-771] (#1741) 2026-01-29 14:17:33 +13:00
Hannah Cooper
ec82f646a0 Add 2.38.0 to bug report (#1756) 2026-01-29 12:45:23 +13:00
andres-portainer
2f0e384240 fix(database): use Exists() where possible to improve performance BE-12557 (#1752) 2026-01-28 18:49:32 -03:00
Ali
19a1426869 chore(webpack): cache dependencies and use lighter sourcemap [R8S-791] (#1715) 2026-01-29 09:52:11 +13:00
andres-portainer
cc5cd8db6b fix(pendingactions): clean up and optimize the code BE-12556 (#1750) 2026-01-28 15:36:54 -03:00
andres-portainer
e384e2edda fix(pendingactions): fix transaction handling BE-12556 (#1749) 2026-01-28 14:11:35 -03:00
Chaim Lev-Ari
dca044873f feat(environments): migrate edge form to react BE-12529 (#1676) 2026-01-28 15:35:13 +07:00
nickl-portainer
8aadddcc68 test(react): add test coverage for forms to enforce no errors showing on initial load [R8S-730] (#1696) 2026-01-28 08:12:12 +13:00
andres-portainer
2e95229c51 fix(oauth): add a timeout to GetResource() BE-12258 (#1456) 2026-01-27 10:24:45 -03:00
Phil Calder
8a1d02c23f Bump version to 2.38.0 (#1727) 2026-01-27 16:26:14 +13:00
283 changed files with 12316 additions and 8911 deletions

View File

@@ -139,15 +139,18 @@ overrides:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
plugins:
- '@vitest'
extends:
- 'plugin:vitest/recommended'
- 'plugin:@vitest/legacy-recommended'
env:
'vitest/env': true
'@vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'@vitest/no-conditional-expect': warn
- files:
- app/**/*.stories.*
rules:
@@ -155,3 +158,4 @@ overrides:
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'storybook/no-renderer-packages': off

View File

@@ -94,10 +94,13 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.38.1'
- '2.38.0'
- '2.37.0'
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.7'
- '2.33.6'
- '2.33.5'
- '2.33.4'

View File

@@ -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:

View File

@@ -1,2 +1,3 @@
dist
api/datastore/test_data
api/datastore/test_data
coverage

View File

@@ -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,
},
],
},
},
],

View File

@@ -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
View 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)

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
)

View File

@@ -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

View 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)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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"`
@@ -107,6 +109,10 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
securitySettings.AllowSysctlSettingForRegularUsers = *payload.AllowSysctlSettingForRegularUsers
}
if payload.AllowSecurityOptForRegularUsers != nil {
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
}
if payload.EnableHostManagementFeatures != nil {
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
}

View File

@@ -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")
}
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.37.0
// @version 2.39.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -95,7 +95,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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")
}
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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"))
}
}

View File

@@ -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")
)
@@ -170,13 +171,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 +228,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
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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())
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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())
}

View File

@@ -30,8 +30,11 @@ func NewService() Service {
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
// from the resource server and matching it with the user identifier setting.
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
token, err := GetOAuthToken(code, configuration)
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
token, err := GetOAuthToken(ctx, code, configuration)
if err != nil {
log.Error().Err(err).Msg("failed retrieving oauth token")
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
log.Error().Err(err).Msg("failed parsing id_token")
}
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
if err != nil {
log.Error().Err(err).Msg("failed retrieving resource")
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
return username, nil
}
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return nil, err
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
config := buildConfig(configuration)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
return config.Exchange(ctx, unescapedCode)
}
@@ -87,9 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
jwtParser := jwt.Parser{
SkipClaimsValidation: true,
}
jwtParser := jwt.Parser{SkipClaimsValidation: true}
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
if err != nil {
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
func GetResource(token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
if err != nil {
return nil, err
}
client := &http.Client{}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}

View File

@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
code := ""
if _, err := GetOAuthToken(code, config); err == nil {
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
}
})
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
code := validCode
token, err := GetOAuthToken(code, config)
token, err := GetOAuthToken(t.Context(), code, config)
if token == nil || err != nil {
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
defer srv.Close()
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource("", config.ResourceURI); err == nil {
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
}
})
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
}
})
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
}
})
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
defer srv.Close()
if _, err := authService.Authenticate(code, config); err == nil {
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
}
})
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, config)
defer srv.Close()
username, err := authService.Authenticate(code, config)
username, err := authService.Authenticate(t.Context(), code, config)
if err != nil {
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
}

View File

@@ -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
}

View File

@@ -355,6 +355,20 @@ type (
CreatedBy string `example:"admin"`
}
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig struct {
// Path to a Helm chart folder for Helm git deployments
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
// Array of paths to Helm values YAML files for Helm git deployments
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
Version string `json:"Version,omitempty" example:"1.2.3"`
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
Atomic bool `json:"Atomic" example:"true"`
// Timeout for Helm operations (equivalent to helm --timeout flag)
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
}
EdgeStackStatusForEnv struct {
EndpointID EndpointID
Status []EdgeStackDeploymentStatus
@@ -544,11 +558,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 +576,7 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
@@ -584,7 +603,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 +647,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 +1836,7 @@ type (
// OAuthService represents a service used to authenticate users using OAuth
OAuthService interface {
Authenticate(code string, configuration *OAuthSettings) (string, error)
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
}
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
@@ -1855,9 +1876,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.37.0"
APIVersion = "2.39.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
@@ -2458,6 +2479,7 @@ func DefaultEndpointSecuritySettings() EndpointSecuritySettings {
AllowHostNamespaceForRegularUsers: false,
AllowPrivilegedModeForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowSecurityOptForRegularUsers: false,
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,

View File

@@ -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 {

View File

@@ -56,6 +56,10 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
return errors.New("sysctl setting disabled for non administrator users")
}
if !securitySettings.AllowSecurityOptForRegularUsers && len(service.SecurityOpt) > 0 {
return errors.New("security-opt setting disabled for non administrator users")
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
return errors.New("container capabilities disabled for non administrator users")
}

View File

@@ -88,7 +88,7 @@ div.input-mask {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
background: var(--bg-widget-color);
border: 1px solid var(--border-widget);
border-radius: 8px;
border-radius: 12px;
}
.widget .widget-header .pagination,
.widget .widget-footer .pagination {
@@ -103,7 +103,7 @@ div.input-mask {
.widget .widget-body {
padding: 20px;
border-radius: 8px;
border-radius: 12px;
}
.widget .widget-body table thead {
background: var(--bg-widget-table-color);

View File

@@ -24,6 +24,7 @@ export default class DockerFeaturesConfigurationController {
disableDeviceMappingForRegularUsers: false,
disableContainerCapabilitiesForRegularUsers: false,
disableSysctlSettingForRegularUsers: false,
disableSecurityOptForRegularUsers: false,
};
this.isAgent = false;
@@ -48,6 +49,7 @@ export default class DockerFeaturesConfigurationController {
this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
this.onChangeDisableSecurityOptForRegularUsers = this.onChangeField('disableSecurityOptForRegularUsers');
}
onToggleAutoUpdate(value) {
@@ -93,6 +95,7 @@ export default class DockerFeaturesConfigurationController {
disableDeviceMappingForRegularUsers,
disableContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers,
disableSecurityOptForRegularUsers,
} = this.formValues;
return (
disableBindMountsForRegularUsers ||
@@ -100,7 +103,8 @@ export default class DockerFeaturesConfigurationController {
disablePrivilegedModeForRegularUsers ||
disableDeviceMappingForRegularUsers ||
disableContainerCapabilitiesForRegularUsers ||
disableSysctlSettingForRegularUsers
disableSysctlSettingForRegularUsers ||
disableSecurityOptForRegularUsers
);
}
@@ -122,6 +126,7 @@ export default class DockerFeaturesConfigurationController {
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
allowSecurityOptForRegularUsers: !this.formValues.disableSecurityOptForRegularUsers,
enableGPUManagement: this.state.enableGPUManagement,
gpus,
};
@@ -159,6 +164,7 @@ export default class DockerFeaturesConfigurationController {
disableStackManagementForRegularUsers: !securitySettings.allowStackManagementForRegularUsers,
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
disableSecurityOptForRegularUsers: !securitySettings.allowSecurityOptForRegularUsers,
};
// this.endpoint.Gpus could be null as it is Gpus: []Pair in the API

View File

@@ -142,6 +142,17 @@
></por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="$ctrl.formValues.disableSecurityOptForRegularUsers"
name="'disableSecurityOptForRegularUsers'"
label="'Disable security-opt for non-administrators'"
label-class="'col-sm-7 col-lg-4'"
on-change="($ctrl.onChangeDisableSecurityOptForRegularUsers)"
></por-switch-field>
</div>
</div>
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
<span class="col-sm-12 text-muted small">

View File

@@ -61,11 +61,8 @@ angular
.config(configApp);
if (require) {
const req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
req
.keys()
.filter((path) => !path.includes('.test'))
.forEach(function (key) {
req(key);
});
const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im);
req.keys().forEach(function (key) {
req(key);
});
}

View File

@@ -195,8 +195,7 @@ angular
},
views: {
'content@': {
templateUrl: './views/endpoints/edit/endpoint.html',
controller: 'EndpointController',
component: 'environmentsItemView',
},
},
};
@@ -256,8 +255,12 @@ angular
url: '/:id',
views: {
'content@': {
templateUrl: './views/groups/edit/group.html',
controller: 'GroupController',
component: 'environmentGroupEditView',
},
},
params: {
id: {
type: 'int',
},
},
};
@@ -267,8 +270,7 @@ angular
url: '/new',
views: {
'content@': {
templateUrl: './views/groups/create/creategroup.html',
controller: 'CreateGroupController',
component: 'environmentGroupCreateView',
},
},
};

View File

@@ -1,17 +0,0 @@
import angular from 'angular';
import GroupFormController from './groupFormController';
angular.module('portainer.app').component('groupForm', {
templateUrl: './groupForm.html',
controller: GroupFormController,
bindings: {
loaded: '<',
model: '=',
associatedEndpoints: '=',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
onChangeEnvironments: '<',
},
});

View File

@@ -1,74 +1,10 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay';
import { EdgeAgentDeploymentWidget } from '@/react/portainer/environments/ItemView/EdgeAgentDeploymentWidget/EdgeAgentDeploymentWidget';
import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl';
import { TagsDatatable } from '@/react/portainer/environments/TagsView/TagsDatatable';
import { EnvironmentBasicConfigSection } from '@/react/portainer/environments/ItemView/EnvironmentBasicConfigSection/EnvironmentBasicConfigSection';
import { EdgeInformationPanel } from '@/react/portainer/environments/ItemView/EdgeInformationPanel/EdgeInformationPanel';
import { KubeConfigInfo } from '@/react/portainer/environments/ItemView/KubeConfigInfo/KubeConfigInfo';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AzureEnvironmentForm } from '@/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm';
import { GeneralEnvironmentForm } from '@/react/portainer/environments/ItemView/GeneralEnvironmentForm/GeneralEnvironmentForm';
export const environmentsModule = angular
.module('portainer.app.react.components.environments', [])
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey']))
.component(
'edgeAgentDeploymentWidget',
r2a(withCurrentUser(withUIRouter(EdgeAgentDeploymentWidget)), [
'edgeKey',
'edgeId',
'asyncMode',
])
)
.component(
'kubeConfigInfo',
r2a(withUIRouter(withReactQuery(KubeConfigInfo)), [
'environmentId',
'environmentType',
'edgeId',
'status',
])
)
.component('kvmControl', r2a(KVMControl, ['deviceId', 'server', 'token']))
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove']))
.component(
'environmentBasicConfigSection',
r2a(EnvironmentBasicConfigSection, [
'values',
'setValues',
'isEdge',
'isAzure',
'isAgent',
'hasError',
'isLocalEnvironment',
])
)
.component(
'edgeInformationPanel',
r2a(withUIRouter(withReactQuery(EdgeInformationPanel)), [
'environmentId',
'edgeKey',
'edgeId',
'platformName',
'onSuccess',
])
)
.component(
'azureEnvironmentForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(AzureEnvironmentForm))), [
'environment',
'onSuccess',
])
)
.component(
'generalEnvironmentForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(GeneralEnvironmentForm))), [
'environment',
'onSuccess',
])
).name;
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name;

View File

@@ -6,8 +6,6 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
@@ -271,20 +269,7 @@ export const ngModule = angular
'inlineLoader',
r2a(InlineLoader, ['children', 'className', 'size'])
)
.component(
'groupAssociationTable',
r2a(withReactQuery(GroupAssociationTable), [
'onClickRow',
'query',
'title',
'data-cy',
])
)
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
.component(
'associatedEndpointsSelector',
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
);
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
export const componentsModule = ngModule.name;

View File

@@ -5,10 +5,20 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/portainer/environments/environment-groups/ListView';
import { EditGroupView } from '@/react/portainer/environments/environment-groups/ItemView/EditGroupView';
import { CreateGroupView } from '@/react/portainer/environments/environment-groups/CreateView/CreateGroupView';
export const environmentGroupModule = angular
.module('portainer.app.react.views.environment-groups', [])
.component(
'environmentGroupsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
)
.component(
'environmentGroupEditView',
r2a(withUIRouter(withReactQuery(withCurrentUser(EditGroupView))), [])
)
.component(
'environmentGroupCreateView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateGroupView))), [])
).name;

View File

@@ -0,0 +1,23 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/portainer/environments/ListView';
import { EdgeAutoCreateScriptViewWrapper } from '@/react/portainer/environments/EdgeAutoCreateScriptView/EdgeAutoCreateScriptView';
import { ItemView } from '@/react/portainer/environments/ItemView/ItemView';
export const environmentsModule = angular
.module('portainer.app.react.views.environments', [])
.component(
'environmentsListView',
r2a(withUIRouter(withCurrentUser(ListView)), [])
)
.component(
'environmentsItemView',
r2a(withUIRouter(withCurrentUser(ItemView)), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(withUIRouter(withCurrentUser(EdgeAutoCreateScriptViewWrapper)), [])
).name;

View File

@@ -7,8 +7,6 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
@@ -21,6 +19,7 @@ import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
import { templatesModule } from './templates';
import { usersModule } from './users';
import { environmentsModule } from './environments';
export const viewsModule = angular
.module('portainer.app.react.views', [
@@ -32,18 +31,12 @@ export const viewsModule = angular
activityLogsModule,
templatesModule,
usersModule,
environmentsModule,
])
.component(
'homeView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(
withUIRouter(withReactQuery(withCurrentUser(EdgeAutoCreateScriptView))),
[]
)
)
.component(
'createUserAccessToken',
r2a(
@@ -58,10 +51,7 @@ export const viewsModule = angular
['onSubmit', 'settings']
)
)
.component(
'environmentsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
)
.component(
'backupSettingsPanel',
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])

View File

@@ -1,181 +0,0 @@
<page-header ng-if="endpoint" title="'Environment details'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, endpoint.Name]" reload="true"> </page-header>
<div>
<div class="mx-4 space-y-4 [&>*]:block mb-4" ng-if="state.edgeEndpoint">
<edge-information-panel
ng-if="state.edgeAssociated"
environment-id="endpoint.Id"
edge-key="endpoint.EdgeKey"
edge-id="endpoint.EdgeID"
platform-name="state.platformName"
on-success="(onDisassociateSuccess)"
>
</edge-information-panel>
<div ng-if="!state.edgeAssociated">
<edge-agent-deployment-widget edge-key="endpoint.EdgeKey" edge-id="endpoint.EdgeID" async-mode="endpoint.Edge.AsyncMode"></edge-agent-deployment-widget>
</div>
</div>
<div class="mx-4 space-y-4 [&>*]:block mb-4">
<kube-config-info environment-id="endpoint.Id" environment-type="endpoint.Type" edge-id="endpoint.EdgeID" status="endpoint.Status"></kube-config-info>
</div>
</div>
<div ng-if="state.azureEndpoint" class="col-sm-12">
<azure-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></azure-environment-form>
</div>
<div class="row mt-4" ng-if="!state.azureEndpoint && !state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12" ng-if="endpoint">
<general-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></general-environment-form>
</div>
</div>
<div class="row mt-4" ng-if="state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.endpointForm">
<environment-basic-config-section
values="basicConfigValues"
set-values="updateBasicConfig"
is-edge="state.edgeEndpoint"
is-azure="state.azureEndpoint"
is-agent="state.agentEndpoint"
has-error="endpoint.Status === 4"
is-local-environment="endpointType === 'local'"
></environment-basic-config-section>
<div ng-if="endpoint && state.edgeEndpoint">
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
<edge-checkin-interval-field value="endpoint.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
</div>
<!-- !endpoint-public-url-input -->
<tls-fieldset
ng-if="!state.edgeEndpoint && endpoint.Status !== 4 && state.showTLSConfig"
values="formValues.tlsConfig"
on-change="(onChangeTLSConfigFormValues)"
validation-data="{optionalCert: true}"
></tls-fieldset>
<div class="col-sm-12 form-section-title"> Metadata </div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left"> Group </label>
<div class="col-sm-9 col-lg-10">
<select
ng-options="group.Id as group.Name for group in groups"
ng-model="endpoint.GroupId"
id="endpoint_group"
class="form-control"
data-cy="endpoint-group-select"
></select>
</div>
</div>
<!-- !group -->
<tag-selector ng-if="endpoint" value="endpoint.TagIds" allow-create="state.allowCreate" on-change="(onChangeTags)"></tag-selector>
<!-- open-amt info -->
<div ng-if="state.showAMTInfo">
<div class="col-sm-12 form-section-title"> Open Active Management Technology </div>
<div class="form-group">
<label for="endpoint_managementinfoVersion" class="col-sm-3 col-lg-2 control-label text-left"> AMT Version </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoVersion"
ng-model="endpoint.ManagementInfo['AMT']"
placeholder="Loading..."
data-cy="endpoint-managementinfoVersion"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoUUID" class="col-sm-3 col-lg-2 control-label text-left"> UUID </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoUUID"
ng-model="endpoint.ManagementInfo['UUID']"
placeholder="Loading..."
data-cy="endpoint-managementinfoUUID"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoBuildNumber" class="col-sm-3 col-lg-2 control-label text-left"> Build Number </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoBuildNumber"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoBuildNumber"
ng-model="endpoint.ManagementInfo['Build Number']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoControlMode" class="col-sm-3 col-lg-2 control-label text-left"> Control Mode </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoControlMode"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoControlMode"
ng-model="endpoint.ManagementInfo['Control Mode']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoDNSSuffix" class="col-sm-3 col-lg-2 control-label text-left"> DNS Suffix </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoDNSSuffix"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoDNSSuffix"
ng-model="endpoint.ManagementInfo['DNS Suffix']"
placeholder="Loading..."
/>
</div>
</div>
</div>
<!-- !open-amt info -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || !$ctrl.endpointForm.$valid"
ng-click="updateEndpoint()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update environment</span>
<span ng-show="state.actionInProgress">Updating environment...</span>
</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.endpoints">Cancel</a>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,342 +0,0 @@
import _ from 'lodash-es';
import uuidv4 from 'uuid/v4';
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
import { confirmDestructive } from '@@/modals/confirm';
import { getPlatformTypeName, isEdgeEnvironment, isDockerAPIEnvironment } from '@/react/portainer/environments/utils';
import { buildConfirmButton } from '@@/modals/utils';
import { getInfo } from '@/react/docker/proxy/queries/useInfo';
angular.module('portainer.app').controller('EndpointController', EndpointController);
/* @ngInject */
function EndpointController(
$async,
$scope,
$state,
$transition$,
$filter,
clipboard,
EndpointService,
GroupService,
Notifications,
Authentication,
SettingsService
) {
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
$scope.setFieldValue = setFieldValue;
$scope.onChangeTags = onChangeTags;
$scope.onChangeTLSConfigFormValues = onChangeTLSConfigFormValues;
$scope.onDisassociateSuccess = onDisassociateSuccess;
$scope.state = {
platformName: '',
selectAll: false,
// displayTextFilter: false,
get selectedItemCount() {
return $scope.state.selectedItems.length || 0;
},
selectedItems: [],
uploadInProgress: false,
actionInProgress: false,
azureEndpoint: false,
kubernetesEndpoint: false,
agentEndpoint: false,
edgeEndpoint: false,
edgeAssociated: false,
allowCreate: Authentication.isAdmin(),
allowSelfSignedCerts: true,
showAMTInfo: false,
showTLSConfig: false,
};
$scope.basicConfigValues = {
name: '',
url: '',
publicUrl: '',
};
$scope.selectAll = function () {
$scope.state.firstClickedItem = null;
for (var i = 0; i < $scope.state.filteredDataSet.length; i++) {
var item = $scope.state.filteredDataSet[i];
if (item.Checked !== $scope.state.selectAll) {
// if ($scope.allowSelection(item) && item.Checked !== $scope.state.selectAll) {
item.Checked = $scope.state.selectAll;
$scope.selectItem(item);
}
}
};
function isBetween(value, a, b) {
return (value >= a && value <= b) || (value >= b && value <= a);
}
$scope.selectItem = function (item, event) {
// Handle range select using shift
if (event && event.originalEvent.shiftKey && $scope.state.firstClickedItem) {
const firstItemIndex = $scope.state.filteredDataSet.indexOf($scope.state.firstClickedItem);
const lastItemIndex = $scope.state.filteredDataSet.indexOf(item);
const itemsInRange = _.filter($scope.state.filteredDataSet, (item, index) => {
return isBetween(index, firstItemIndex, lastItemIndex);
});
const value = $scope.state.firstClickedItem.Checked;
_.forEach(itemsInRange, (i) => {
i.Checked = value;
});
$scope.state.firstClickedItem = item;
} else if (event) {
item.Checked = !item.Checked;
$scope.state.firstClickedItem = item;
}
$scope.state.selectedItems = _.uniq(_.concat($scope.state.selectedItems, $scope.state.filteredDataSet)).filter((i) => i.Checked);
if (event && $scope.state.selectAll && $scope.state.selectedItems.length !== $scope.state.filteredDataSet.length) {
$scope.state.selectAll = false;
}
};
$scope.formValues = {
tlsConfig: {
tls: false,
skipVerify: false,
skipClientVerify: false,
caCertFile: null,
certFile: null,
keyFile: null,
},
};
function onDisassociateSuccess() {
$state.reload();
}
function onChangeCheckInInterval(value) {
setFieldValue('EdgeCheckinInterval', value);
}
function onChangeTags(value) {
setFieldValue('TagIds', value);
}
function onChangeTLSConfigFormValues(newValues) {
return this.$async(async () => {
$scope.formValues.tlsConfig = {
...$scope.formValues.tlsConfig,
...newValues,
};
});
}
function setFieldValue(name, value) {
return $scope.$evalAsync(() => {
$scope.endpoint = {
...$scope.endpoint,
[name]: value,
};
});
}
Array.prototype.indexOf = function (val) {
for (var i = 0; i < this.length; i++) {
if (this[i] == val) return i;
}
return -1;
};
Array.prototype.remove = function (val) {
var index = this.indexOf(val);
if (index > -1) {
this.splice(index, 1);
}
};
$scope.updateEndpoint = async function () {
var endpoint = $scope.endpoint;
if (isEdgeEnvironment(endpoint.Type) && _.difference($scope.initialTagIds, endpoint.TagIds).length > 0) {
let confirmed = await confirmDestructive({
title: 'Confirm action',
message: 'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
confirmButton: buildConfirmButton(),
});
if (!confirmed) {
return;
}
}
var payload = {
Name: endpoint.Name,
PublicURL: endpoint.PublicURL,
Gpus: endpoint.Gpus,
GroupID: endpoint.GroupId,
TagIds: endpoint.TagIds,
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
};
if (
$scope.endpointType !== 'local' &&
endpoint.Type !== PortainerEndpointTypes.AzureEnvironment &&
endpoint.Type !== PortainerEndpointTypes.KubernetesLocalEnvironment &&
endpoint.Type !== PortainerEndpointTypes.AgentOnKubernetesEnvironment
) {
payload.URL = 'tcp://' + endpoint.URL;
if (endpoint.Type === PortainerEndpointTypes.DockerEnvironment) {
var tlsConfig = $scope.formValues.tlsConfig;
payload.TLS = tlsConfig.tls;
payload.TLSSkipVerify = tlsConfig.skipVerify;
if (tlsConfig.tls && !tlsConfig.skipVerify) {
payload.TLSSkipClientVerify = tlsConfig.skipClientVerify;
payload.TLSCACert = tlsConfig.caCertFile;
payload.TLSCert = tlsConfig.certFile;
payload.TLSKey = tlsConfig.keyFile;
}
}
}
if (endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment) {
payload.URL = endpoint.URL;
}
if (endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment) {
payload.URL = 'https://' + endpoint.URL;
}
$scope.state.actionInProgress = true;
EndpointService.updateEndpoint(endpoint.Id, payload).then(
function success() {
onUpdateSuccess();
},
function error(err) {
Notifications.error('Failure', err, 'Unable to update environment');
$scope.state.actionInProgress = false;
},
function update(evt) {
if (evt.upload) {
$scope.state.uploadInProgress = evt.upload;
}
}
);
};
function onUpdateSuccess() {
Notifications.success('Environment updated', $scope.endpoint.Name);
$state.go($state.params.redirectTo || 'portainer.endpoints', {}, { reload: true });
}
function configureState() {
$scope.state.platformName = getPlatformTypeName($scope.endpoint.Type);
if (
$scope.endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.kubernetesEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || $scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.AzureEnvironment) {
$scope.state.azureEndpoint = true;
}
if (
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.agentEndpoint = true;
}
}
function configureTLS(endpoint) {
$scope.formValues = {
tlsConfig: {
tls: endpoint.TLSConfig.TLS || false,
skipVerify: endpoint.TLSConfig.TLSSkipVerify || false,
skipClientVerify: endpoint.TLSConfig.TLSSkipClientVerify || false,
},
};
}
async function initView() {
return $async(async () => {
try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
if (isDockerAPIEnvironment(endpoint)) {
$scope.state.showTLSConfig = true;
}
// Check if the environment is docker standalone, to decide whether to show the GPU insights box
const isDockerEnvironment = endpoint.Type === PortainerEndpointTypes.DockerEnvironment;
if (isDockerEnvironment) {
try {
const dockerInfo = await getInfo(endpoint.Id);
const isDockerSwarmEnv = dockerInfo.Swarm && dockerInfo.Swarm.NodeID;
$scope.isDockerStandaloneEnv = !isDockerSwarmEnv;
} catch (err) {
// $scope.isDockerStandaloneEnv is only used to show the "GPU insights box", so fail quietly on error
}
}
if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) {
$scope.endpointType = 'local';
} else {
$scope.endpointType = 'remote';
}
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeAssociated = !!endpoint.EdgeID;
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
}
$scope.endpoint = endpoint;
$scope.initialTagIds = endpoint.TagIds.slice();
$scope.groups = groups;
configureState();
configureTLS(endpoint);
if (EndpointHelper.isDockerEndpoint(endpoint) && $scope.state.edgeAssociated) {
$scope.state.showAMTInfo = settings && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
}
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve environment details');
}
if ($scope.state.showAMTInfo) {
try {
$scope.endpoint.ManagementInfo = {};
const amtInfo = await getAMTInfo($state.params.id);
try {
$scope.endpoint.ManagementInfo = JSON.parse(amtInfo.RawOutput);
} catch (err) {
clearAMTManagementInfo(amtInfo.RawOutput);
}
} catch (err) {
clearAMTManagementInfo('Unable to retrieve AMT environment details');
}
}
});
}
function clearAMTManagementInfo(versionValue) {
$scope.endpoint.ManagementInfo['AMT'] = versionValue;
$scope.endpoint.ManagementInfo['UUID'] = '-';
$scope.endpoint.ManagementInfo['Control Mode'] = '-';
$scope.endpoint.ManagementInfo['Build Number'] = '-';
$scope.endpoint.ManagementInfo['DNS Suffix'] = '-';
}
initView();
}

View File

@@ -1,40 +0,0 @@
import { EndpointGroupDefaultModel } from '../../../models/group';
angular.module('portainer.app').controller('CreateGroupController', function CreateGroupController($async, $scope, $state, GroupService, Notifications) {
$scope.state = {
actionInProgress: false,
};
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.create = function () {
var model = $scope.model;
$scope.state.actionInProgress = true;
GroupService.createGroup(model, $scope.associatedEndpoints)
.then(function success() {
Notifications.success('Success', 'Group successfully created');
$state.go('portainer.groups', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function initView() {
$scope.associatedEndpoints = [];
$scope.model = new EndpointGroupDefaultModel();
$scope.loaded = true;
}
function onChangeEnvironments(value) {
return $scope.$evalAsync(() => {
$scope.associatedEndpoints = value;
});
}
initView();
});

View File

@@ -1,19 +0,0 @@
<page-header title="'Create environment group'" breadcrumbs="[{label:'Environment groups', link:'portainer.groups'}, 'Add group']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
loaded="loaded"
model="model"
associated-endpoints="associatedEndpoints"
form-action="create"
form-action-label="Create the group"
action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,19 +0,0 @@
<page-header title="'Environment group details'" breadcrumbs="[{label:'Groups', link:'portainer.groups'}, group.Name]" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
loaded="loaded"
model="group"
associated-endpoints="associatedEndpoints"
form-action="update"
form-action-label="Update the group"
action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,83 +0,0 @@
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
angular.module('portainer.app').controller('GroupController', function GroupController($async, $q, $scope, $state, $transition$, GroupService, Notifications) {
$scope.state = {
actionInProgress: false,
};
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.associatedEndpoints = [];
$scope.update = function () {
var model = $scope.group;
$scope.state.actionInProgress = true;
GroupService.updateGroup(model)
.then(function success() {
Notifications.success('Success', 'Group successfully updated');
$state.go('portainer.groups', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function onChangeEnvironments(value, meta) {
return $async(async () => {
let success = false;
if (meta.type === 'add') {
success = await onAssociate(meta.value);
} else if (meta.type === 'remove') {
success = await onDisassociate(meta.value);
}
if (success) {
$scope.associatedEndpoints = value;
}
});
}
async function onAssociate(endpointId) {
try {
await GroupService.addEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully added to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to add environment to group`);
}
}
async function onDisassociate(endpointId) {
try {
await GroupService.removeEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully removed to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to remove environment to group`);
}
}
function initView() {
var groupId = $transition$.params().id;
$q.all({
group: GroupService.group(groupId),
endpoints: getEnvironments({ query: { groupIds: [groupId] } }),
})
.then(function success(data) {
$scope.group = data.group;
$scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id);
$scope.loaded = true;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load group details');
});
}
initView();
});

View File

@@ -135,6 +135,7 @@ export function createMockEnvironment(
allowHostNamespaceForRegularUsers: false,
allowStackManagementForRegularUsers: false,
allowSysctlSettingForRegularUsers: false,
allowSecurityOptForRegularUsers: false,
allowVolumeBrowserForRegularUsers: false,
enableHostManagementFeatures: false,
},

View File

@@ -1,6 +1,6 @@
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
@@ -17,31 +17,50 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
})),
}));
test('submit button should be disabled when name or image is missing', async () => {
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateContainerInstanceForm), user)
);
const { findByText, getByText, getByLabelText } = render(<Wrapped />);
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
return render(<Wrapped />);
}
const button = getByText(/Deploy the container/);
expect(button).toBeVisible();
expect(button).toBeDisabled();
describe('CreateContainerInstanceForm', () => {
beforeEach(() => {
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
});
const nameInput = getByLabelText(/name/i, { selector: 'input' });
await userEvent.type(nameInput, 'name');
// TODO: from R8S-730 - enable this test once it passes
it.skip('should not display any visible error messages on initial load', async () => {
renderComponent();
const imageInput = getByLabelText(/image/i, { selector: 'input' });
await userEvent.type(imageInput, 'image');
const errors = await screen.findByRole('alert');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
// Check that no error messages (role="alert") are visible
expect(errors).not.toBeInTheDocument();
});
expect(nameInput).toHaveValue('name');
await userEvent.clear(nameInput);
it('submit button should be disabled when name or image is missing', async () => {
const { findByText, getByText, getByLabelText } = renderComponent();
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
const button = getByText(/Deploy the container/);
expect(button).toBeVisible();
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i, { selector: 'input' });
await userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i, { selector: 'input' });
await userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
expect(nameInput).toHaveValue('name');
await userEvent.clear(nameInput);
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});
});

View File

@@ -1,27 +1,15 @@
import { Field, Form, Formik } from 'formik';
import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { Plus } from 'lucide-react';
import { ContainerInstanceFormValues } from '@/react/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { useCurrentUser } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { validationSchema } from './CreateContainerInstanceForm.validation';
import { PortsMappingField } from './PortsMappingField';
import { useFormState, useLoadFormState } from './useLoadFormState';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from './utils';
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
import { CreateContainerInstanceInnerForm } from './CreateContainerInstanceInnerForm';
export function CreateContainerInstanceForm({
defaultValues,
@@ -61,161 +49,15 @@ export function CreateContainerInstanceForm({
validateOnChange
enableReinitialize
>
{({
errors,
handleSubmit,
isSubmitting,
isValid,
values,
setFieldValue,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl
label="Subscription"
inputId="subscription-input"
errors={errors.subscription}
>
<Field
name="subscription"
as={Select}
id="subscription-input"
options={subscriptionOptions}
/>
</FormControl>
<FormControl
label="Resource group"
inputId="resourceGroup-input"
errors={errors.resourceGroup}
>
<Field
name="resourceGroup"
as={Select}
id="resourceGroup-input"
options={getSubscriptionResourceGroups(
values.subscription,
resourceGroups
)}
/>
</FormControl>
<FormControl
label="Location"
inputId="location-input"
errors={errors.location}
>
<Field
name="location"
as={Select}
id="location-input"
options={getSubscriptionLocations(values.subscription, providers)}
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input" errors={errors.name}>
<Field
name="name"
as={Input}
id="name-input"
placeholder="e.g. myContainer"
/>
</FormControl>
<FormControl
label="Image"
inputId="image-input"
errors={errors.image}
>
<Field
name="image"
as={Input}
id="image-input"
placeholder="e.g. nginx:alpine"
/>
</FormControl>
<FormControl label="OS" inputId="os-input" errors={errors.os}>
<Field
name="os"
as={Select}
id="os-input"
options={[
{ label: 'Linux', value: 'Linux' },
{ label: 'Windows', value: 'Windows' },
]}
/>
</FormControl>
<PortsMappingField
value={values.ports}
onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports}
/>
<EnvironmentVariablesPanel
values={values.env}
onChange={(env) => setFieldValue('env', env)}
errors={errors.env}
/>
<div className="form-group">
<div className="col-sm-12 small text-muted">
This will automatically deploy a container with a public IP
address
</div>
</div>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
<Field
name="cpu"
as={Input}
id="cpu-input"
type="number"
placeholder="1"
/>
</FormControl>
<FormControl
label="Memory"
inputId="cpu-input"
errors={errors.memory}
>
<Field
name="memory"
as={Input}
id="memory-input"
type="number"
placeholder="1"
/>
</FormControl>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
icon={Plus}
data-cy="aci-create-button"
>
Deploy the container
</LoadingButton>
</div>
</div>
</Form>
{(formikProps) => (
<CreateContainerInstanceInnerForm
// eslint-disable-next-line react/jsx-props-no-spreading
{...formikProps}
subscriptionOptions={subscriptionOptions}
environmentId={environmentId}
resourceGroups={resourceGroups}
providers={providers}
/>
)}
</Formik>
);

View File

@@ -0,0 +1,182 @@
import { Field, Form, FormikProps } from 'formik';
import { Plus } from 'lucide-react';
import {
ContainerInstanceFormValues,
ProviderViewModel,
ResourceGroup,
} from '@/react/azure/types';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from '@/react/azure/container-instances/CreateView/utils';
import { PortsMappingField } from '@/react/azure/container-instances/CreateView/PortsMappingField';
import { AccessControlForm } from '@/react/portainer/access-control';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { LoadingButton } from '@@/buttons';
import { Option } from '@@/form-components/PortainerSelect';
type Props = FormikProps<ContainerInstanceFormValues> & {
subscriptionOptions: Option<string>[];
environmentId: number;
resourceGroups: Record<string, ResourceGroup[]>;
providers: Record<string, ProviderViewModel | undefined>;
};
export function CreateContainerInstanceInnerForm({
errors,
handleSubmit,
isSubmitting,
isValid,
values,
setFieldValue,
environmentId,
subscriptionOptions,
resourceGroups,
providers,
}: Props) {
return (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl
label="Subscription"
inputId="subscription-input"
errors={errors.subscription}
>
<Field
name="subscription"
as={Select}
id="subscription-input"
options={subscriptionOptions}
/>
</FormControl>
<FormControl
label="Resource group"
inputId="resourceGroup-input"
errors={errors.resourceGroup}
>
<Field
name="resourceGroup"
as={Select}
id="resourceGroup-input"
options={getSubscriptionResourceGroups(
values.subscription,
resourceGroups
)}
/>
</FormControl>
<FormControl
label="Location"
inputId="location-input"
errors={errors.location}
>
<Field
name="location"
as={Select}
id="location-input"
options={getSubscriptionLocations(values.subscription, providers)}
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input" errors={errors.name}>
<Field
name="name"
as={Input}
id="name-input"
placeholder="e.g. myContainer"
/>
</FormControl>
<FormControl label="Image" inputId="image-input" errors={errors.image}>
<Field
name="image"
as={Input}
id="image-input"
placeholder="e.g. nginx:alpine"
/>
</FormControl>
<FormControl label="OS" inputId="os-input" errors={errors.os}>
<Field
name="os"
as={Select}
id="os-input"
options={[
{ label: 'Linux', value: 'Linux' },
{ label: 'Windows', value: 'Windows' },
]}
/>
</FormControl>
<PortsMappingField
value={values.ports}
onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports}
/>
<EnvironmentVariablesPanel
values={values.env}
onChange={(env) => setFieldValue('env', env)}
errors={errors.env}
/>
<div className="form-group">
<div className="col-sm-12 small text-muted">
This will automatically deploy a container with a public IP address
</div>
</div>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
<Field
name="cpu"
as={Input}
id="cpu-input"
type="number"
placeholder="1"
/>
</FormControl>
<FormControl label="Memory" inputId="cpu-input" errors={errors.memory}>
<Field
name="memory"
as={Input}
id="memory-input"
type="number"
placeholder="1"
/>
</FormControl>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
icon={Plus}
data-cy="aci-create-button"
>
Deploy the container
</LoadingButton>
</div>
</div>
</Form>
);
}

View File

@@ -79,6 +79,7 @@ export type SwarmCreatePayload =
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
webhook?: string;
};
};
@@ -108,6 +109,7 @@ type StandaloneCreatePayload =
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
webhook?: string;
};
};
@@ -126,6 +128,7 @@ type KubernetesCreatePayload =
payload: KubernetesBasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
webhook?: string;
};
}
| {
@@ -198,7 +201,10 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
environmentId: payload.environmentId,
swarmID: payload.swarmId,
additionalFiles: payload.git.AdditionalFiles,
@@ -246,7 +252,10 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
fromAppTemplate: payload.fromAppTemplate,
@@ -291,7 +300,10 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
composeFormat: payload.composeFormat,

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { Alert } from './Alert';
@@ -21,21 +21,21 @@ function Template({ text, color, title }: Args) {
);
}
export const Success: Story<Args> = Template.bind({});
export const Success: StoryFn<Args> = Template.bind({});
Success.args = {
color: 'success',
title: 'Success',
text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text',
};
export const Error: Story<Args> = Template.bind({});
export const Error: StoryFn<Args> = Template.bind({});
Error.args = {
color: 'error',
title: 'Error',
text: 'This is an error alert',
};
export const Info: Story<Args> = Template.bind({});
export const Info: StoryFn<Args> = Template.bind({});
Info.args = {
color: 'info',
title: 'Info',

View File

@@ -96,7 +96,7 @@ export function AlertContainer({
return (
<div
className={clsx(
'border rounded-lg border-solid [&_ul]:ps-8',
'border rounded-xl border-solid [&_ul]:ps-8',
'p-3',
className
)}

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Card, Props } from './Card';
@@ -14,5 +14,5 @@ function Template({
return <Card>{children}</Card>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {};

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { Code } from './Code';
@@ -16,18 +16,18 @@ function Template({ text, showCopyButton }: Args) {
return <Code showCopyButton={showCopyButton}>{text}</Code>;
}
export const Primary: Story<Args> = Template.bind({});
export const Primary: StoryFn<Args> = Template.bind({});
Primary.args = {
text: 'curl -X GET http://ultra-sound-money.eth',
showCopyButton: true,
};
export const MultiLine: Story<Args> = Template.bind({});
export const MultiLine: StoryFn<Args> = Template.bind({});
MultiLine.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
};
export const MultiLineWithIcon: Story<Args> = Template.bind({});
export const MultiLineWithIcon: StoryFn<Args> = Template.bind({});
MultiLineWithIcon.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
showCopyButton: true,

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { List } from 'lucide-react';
import { Link } from '@@/Link';
@@ -29,7 +29,7 @@ function Template({ value, icon, type }: StoryProps) {
);
}
export const Primary: Story<StoryProps> = Template.bind({});
export const Primary: StoryFn<StoryProps> = Template.bind({});
Primary.args = {
value: 1,
icon: List,

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { DetailsTable } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
@@ -24,7 +24,7 @@ function Template({ key1, val1, key2, val2 }: Args) {
);
}
export const Default: Story<Args> = Template.bind({});
export const Default: StoryFn<Args> = Template.bind({});
Default.args = {
key1: 'Name',
val1: 'My Cool App',

View File

@@ -1,4 +1,4 @@
import { Story, Meta } from '@storybook/react';
import { StoryFn, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
@@ -12,7 +12,7 @@ function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { InsightsBox, Props } from './InsightsBox';
@@ -11,7 +11,7 @@ function Template({ header, content }: Props) {
return <InsightsBox header={header} content={content} />;
}
export const Primary: Story<Props> = Template.bind({});
export const Primary: StoryFn<Props> = Template.bind({});
Primary.args = {
header: 'Insights box header',
content: 'This is the content of the insights box',

View File

@@ -1,4 +1,4 @@
import { ComponentMeta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { NavTabs, type Option } from './NavTabs';
@@ -6,7 +6,7 @@ import { NavTabs, type Option } from './NavTabs';
export default {
title: 'Components/NavTabs',
component: NavTabs,
} as ComponentMeta<typeof NavTabs>;
} as Meta<typeof NavTabs>;
type Args = {
options: Option[];
@@ -26,7 +26,7 @@ function Template({ options = [] }: Args) {
);
}
export const Example: Story<Args> = Template.bind({});
export const Example: StoryFn<Args> = Template.bind({});
Example.args = {
options: [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/react/hooks/useUser';
@@ -39,7 +39,7 @@ function Template({ title }: StoryProps) {
);
}
export const Primary: Story<StoryProps> = Template.bind({});
export const Primary: StoryFn<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};

View File

@@ -8,22 +8,6 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { HeaderContainer } from './HeaderContainer';
import { HeaderTitle } from './HeaderTitle';
test('should not render without a wrapping HeaderContainer', async () => {
const consoleErrorFn = vi
.spyOn(console, 'error')
.mockImplementation(() => vi.fn());
const title = 'title';
function renderComponent() {
const Wrapped = withTestQueryProvider(HeaderTitle);
return render(<Wrapped title={title} />);
}
expect(renderComponent).toThrowErrorMatchingSnapshot();
consoleErrorFn.mockRestore();
});
test('should display a HeaderTitle', async () => {
const username = 'username';
const user = new UserViewModel({ Username: username });

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/react/hooks/useUser';
@@ -37,7 +37,7 @@ function Template({ title }: StoryProps) {
);
}
export const Primary: Story<StoryProps> = Template.bind({});
export const Primary: StoryFn<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};

View File

@@ -1,3 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`should not render without a wrapping HeaderContainer 1`] = `[Error: Should be nested inside a HeaderContainer component]`;

View File

@@ -15,7 +15,8 @@ export function StickyFooter({
<div
className={clsx(
styles.actionBar,
'fixed bottom-0 right-0 z-50 h-16',
// The sticky footer should be below the modal overlay `Modal.tsx` and react select menu `ReactSelect.css` (z-50)
'fixed bottom-0 right-0 z-10 h-16',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { TextTip } from './TextTip';
@@ -14,7 +14,7 @@ function Template({
return <TextTip>{children}</TextTip>;
}
export const Primary: Story<PropsWithChildren<unknown>> = Template.bind({});
export const Primary: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
Primary.args = {
children: 'This is a text tip with children',
};

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { Tooltip, Props } from './Tooltip';
@@ -16,7 +16,7 @@ function Template({ message, position }: JSX.IntrinsicAttributes & Props) {
);
}
export const Primary: Story<Props> = Template.bind({});
export const Primary: StoryFn<Props> = Template.bind({});
Primary.args = {
message: 'Tooltip example',
position: 'bottom',

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from '@storybook/react';
import { Meta, StoryFn } from '@storybook/react';
import { ViewLoading } from './ViewLoading';
@@ -15,7 +15,7 @@ function Template({ message }: Args) {
return <ViewLoading message={message} />;
}
export const Example: Story<Args> = Template.bind({});
export const Example: StoryFn<Args> = Template.bind({});
Example.args = {
message: 'Loading...',
};

View File

@@ -0,0 +1,142 @@
import type { Meta, StoryFn } from '@storybook/react';
import { Box, Settings, Users } from 'lucide-react';
import {
ReactStateDeclaration,
UIRouter,
UIRouterReact,
UIView,
hashLocationPlugin,
servicesPlugin,
} from '@uirouter/react';
import { WidgetTabs, Tab } from './WidgetTabs';
// Create a UIRouter instance with a dummy state so `Link to="."` works
function withRouter(Story: () => JSX.Element) {
const router = new UIRouterReact();
router.plugin(servicesPlugin);
router.plugin(hashLocationPlugin);
// Register a dummy state that renders the Story
const storyState: ReactStateDeclaration = {
name: 'storybook',
url: '/?tab',
component: Story,
};
router.stateRegistry.register(storyState);
// Set initial state (UIRouter component calls start() automatically)
router.urlService.rules.initial({ state: 'storybook' });
router.urlService.rules.otherwise({ state: 'storybook' });
return (
<UIRouter router={router}>
<UIView />
</UIRouter>
);
}
const meta: Meta<typeof WidgetTabs> = {
title: 'Components/Widget/WidgetTabs',
component: WidgetTabs,
decorators: [withRouter],
};
export default meta;
const defaultTabs: Tab[] = [
{
name: 'Overview',
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Settings',
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
{
name: 'Users',
widget: <div>Users content</div>,
selectedTabParam: 'users',
},
];
const tabsWithIcons: Tab[] = [
{
name: 'Overview',
icon: Box,
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Settings',
icon: Settings,
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
{
name: 'Users',
icon: Users,
widget: <div>Users content</div>,
selectedTabParam: 'users',
},
];
interface StoryArgs {
currentTabIndex: number;
tabs: Tab[];
useContainer?: boolean;
}
function Template({ currentTabIndex, tabs, useContainer }: StoryArgs) {
return (
<WidgetTabs
currentTabIndex={currentTabIndex}
tabs={tabs}
useContainer={useContainer}
/>
);
}
export const Default: StoryFn<StoryArgs> = Template.bind({});
Default.args = {
currentTabIndex: 0,
tabs: defaultTabs,
};
export const WithIcons: StoryFn<StoryArgs> = Template.bind({});
WithIcons.args = {
currentTabIndex: 0,
tabs: tabsWithIcons,
};
export const SecondTabSelected: StoryFn<StoryArgs> = Template.bind({});
SecondTabSelected.args = {
currentTabIndex: 1,
tabs: tabsWithIcons,
};
export const WithoutContainer: StoryFn<StoryArgs> = Template.bind({});
WithoutContainer.args = {
currentTabIndex: 0,
tabs: tabsWithIcons,
useContainer: false,
};
export const TwoTabs: StoryFn<StoryArgs> = Template.bind({});
TwoTabs.args = {
currentTabIndex: 0,
tabs: [
{
name: 'Tab 1',
widget: <div>Tab 1 content</div>,
selectedTabParam: 'tab1',
},
{
name: 'Tab 2',
widget: <div>Tab 2 content</div>,
selectedTabParam: 'tab2',
},
],
};

View File

@@ -0,0 +1,173 @@
import { render, screen } from '@testing-library/react';
import { Layers } from 'lucide-react';
import { ReactNode } from 'react';
import { vi } from 'vitest';
import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs';
// Mock Link component to avoid ui-router relative state resolution in tests
vi.mock('@@/Link', () => ({
Link: ({
children,
'data-cy': dataCy,
'aria-current': ariaCurrent,
className,
}: {
children: ReactNode;
'data-cy'?: string;
'aria-current'?: 'page' | undefined;
className?: string;
}) => (
<a
data-cy={dataCy}
aria-current={ariaCurrent}
className={className}
href="/"
>
{children}
</a>
),
}));
const mockTabs: Tab[] = [
{
name: 'Overview',
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Details',
widget: <div>Details content</div>,
selectedTabParam: 'details',
},
{
name: 'Settings',
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
];
function renderWidgetTabs(
props: Partial<React.ComponentProps<typeof WidgetTabs>> = {}
) {
const defaultProps = {
currentTabIndex: 0,
tabs: mockTabs,
};
return render(<WidgetTabs {...defaultProps} {...props} />);
}
describe('WidgetTabs', () => {
describe('rendering', () => {
it('renders all tabs and highlights the current tab', () => {
renderWidgetTabs({ currentTabIndex: 1 });
// All tabs should be visible
expect(screen.getByRole('link', { name: 'Overview' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Details' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Settings' })).toBeVisible();
// Only the selected tab should have aria-current="page"
expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute(
'aria-current',
'page'
);
expect(
screen.getByRole('link', { name: 'Overview' })
).not.toHaveAttribute('aria-current');
expect(
screen.getByRole('link', { name: 'Settings' })
).not.toHaveAttribute('aria-current');
});
it('renders tab icons when provided', () => {
const tabsWithIcons: Tab[] = [
{
name: 'Tab 1',
icon: Layers,
widget: <div />,
selectedTabParam: 'tab1',
},
{ name: 'Tab 2', widget: <div />, selectedTabParam: 'tab2' },
];
renderWidgetTabs({ tabs: tabsWithIcons, currentTabIndex: 0 });
// Tab with icon should contain an svg (lucide icon)
const tab1 = screen.getByRole('link', { name: 'Tab 1' });
expect(tab1.querySelector('svg')).toBeVisible();
// Tab without icon should not contain an svg
const tab2 = screen.getByRole('link', { name: 'Tab 2' });
expect(tab2.querySelector('svg')).toBeNull();
});
it('renders without container when useContainer is false', () => {
const { container } = renderWidgetTabs({ useContainer: false });
// Should not have the bootstrap row/col wrapper
expect(container.querySelector('.row')).toBeNull();
expect(container.querySelector('.col-sm-12')).toBeNull();
});
it('renders with container wrapper by default', () => {
const { container } = renderWidgetTabs();
// Should have the bootstrap row/col wrapper
expect(container.querySelector('.row')).toBeVisible();
expect(container.querySelector('.col-sm-12')).toBeVisible();
});
});
describe('error handling', () => {
it('throws an error when any tab has an invalid URL-encodable param value', () => {
// Tabs with characters that change when URL-encoded
const invalidTabs: Tab[] = [
{
name: 'Tab A',
widget: <div />,
selectedTabParam: 'param with spaces',
},
{ name: 'Tab B', widget: <div />, selectedTabParam: 'good-param' },
];
expect(() =>
renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 })
).toThrow('Invalid query param value for tab');
});
});
describe('accessibility', () => {
it('has accessible navigation landmark with label', () => {
renderWidgetTabs();
const nav = screen.getByRole('navigation', {
name: 'Section navigation',
});
expect(nav).toBeVisible();
});
});
});
describe('findSelectedTabIndex', () => {
it('returns the correct index when tab param matches', () => {
const result = findSelectedTabIndex({ tab: 'details' }, mockTabs);
expect(result).toBe(1);
});
it('returns 0 when tab param does not match any tab', () => {
const result = findSelectedTabIndex({ tab: 'nonexistent' }, mockTabs);
expect(result).toBe(0);
});
it('returns 0 when params.tab is undefined', () => {
const result = findSelectedTabIndex({}, mockTabs);
expect(result).toBe(0);
});
it('returns the index of the first tab when params.tab matches first tab', () => {
const result = findSelectedTabIndex({ tab: 'overview' }, mockTabs);
expect(result).toBe(0);
});
});

View File

@@ -15,54 +15,76 @@ export interface Tab {
interface Props {
currentTabIndex: number;
tabs: Tab[];
useContainer?: boolean;
ariaLabel?: string;
}
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?type=design&node-id=148-2676&mode=design&t=JKyBWBupeC5WADk6-0
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
export function WidgetTabs({
currentTabIndex,
tabs,
useContainer = true,
ariaLabel = 'Section navigation',
}: Props) {
// ensure that the selectedTab param is always valid
const invalidQueryParamValue = tabs.every(
const invalidQueryParamValue = tabs.some(
(tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
);
if (invalidQueryParamValue) {
throw new Error('Invalid query param value for tab');
}
return (
<div className="row">
<div className="col-sm-12 !mb-0">
<div className="pl-2">
{tabs.map(({ name, icon }, index) => (
<Link
to="."
params={{ tab: tabs[index].selectedTabParam }}
key={index}
className={clsx(
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
{
'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6':
currentTabIndex === index,
'border-transparent text-gray-7 hover:text-gray-8 th-highcontrast:text-gray-6 hover:th-highcontrast:text-gray-5 th-dark:text-gray-6 hover:th-dark:text-gray-5':
currentTabIndex !== index,
}
)}
data-cy={`tab-${index}`}
>
{icon && <Icon icon={icon} />}
{name}
</Link>
))}
</div>
const tabsComponent = (
<nav
aria-label={ariaLabel}
className={clsx(
'max-w-fit overflow-hidden rounded-xl',
'bg-[var(--bg-widget-color)] border border-solid border-[var(--border-widget)]'
)}
>
{/* additional div, so that the scrollbar doesn't overlap with rounded corners of the nav parent */}
<div className="flex items-center gap-1 p-1 overflow-x-auto">
{tabs.map(({ name, icon }, index) => (
<Link
to="."
params={{ tab: tabs[index].selectedTabParam }}
key={index}
className={clsx(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg',
'hover:no-underline focus:no-underline text-gray-7 th-highcontrast:text-white th-dark:text-gray-6',
'transition-colors duration-200',
{
'border-inherit !bg-graphite-50 !text-graphite-900 hover:text-graphite-900 th-dark:!bg-graphite-600 th-dark:!text-white th-highcontrast:!bg-white th-highcontrast:!text-black':
currentTabIndex === index,
},
{
'bg-transparent hover:bg-graphite-50 th-dark:hover:bg-graphite-600 th-highcontrast:hover:bg-white hover:text-gray-7 th-dark:hover:text-gray-6 th-highcontrast:hover:text-black':
currentTabIndex !== index,
}
)}
data-cy={`tab-${index}`}
aria-current={currentTabIndex === index ? 'page' : undefined}
>
{icon && <Icon icon={icon} />}
{name}
</Link>
))}
</div>
</div>
</nav>
);
if (useContainer) {
return (
<div className="row">
<div className="col-sm-12">{tabsComponent}</div>
</div>
);
}
return tabsComponent;
}
// findSelectedTabIndex returns the index of the tab, or 0 if none is selected
export function findSelectedTabIndex(
{ params }: { params: RawParams },
tabs: Tab[]
) {
export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
const selectedTabParam = params.tab || tabs[0].selectedTabParam;
const currentTabIndex = tabs.findIndex(
(tab) => tab.selectedTabParam === selectedTabParam
@@ -75,7 +97,7 @@ export function findSelectedTabIndex(
export function useCurrentTabIndex(tabs: Tab[]) {
const params = useCurrentStateAndParams();
const currentTabIndex = findSelectedTabIndex(params, tabs);
const currentTabIndex = findSelectedTabIndex(params.params, tabs);
return currentTabIndex;
}

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