Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c372909794 | ||
|
|
988a795def | ||
|
|
3f7a3053ff | ||
|
|
0c8c6865be | ||
|
|
2bbcae39b6 | ||
|
|
caf6b2aa0c | ||
|
|
a00f05fe32 | ||
|
|
9fcac1ab4f | ||
|
|
ae24ad4693 | ||
|
|
0f721b60a9 | ||
|
|
e8b49f53e1 | ||
|
|
27531a802b | ||
|
|
4bbf0ce0c0 | ||
|
|
e0c22ea3eb | ||
|
|
b7eb2ba068 | ||
|
|
affdb69568 | ||
|
|
763b7da65c | ||
|
|
42e9165347 | ||
|
|
16dd08a359 | ||
|
|
936494615c | ||
|
|
5769c0b98e | ||
|
|
b7e1caa8c6 | ||
|
|
e02ae6b2fb | ||
|
|
d9f131a2c5 | ||
|
|
ad1f7dbaa5 | ||
|
|
aa6da0f6d3 | ||
|
|
376071e408 | ||
|
|
d3544fb9b3 | ||
|
|
c8497b3944 | ||
|
|
5aa92b8413 | ||
|
|
bccb6694d4 | ||
|
|
506a11c658 | ||
|
|
bdc315a59d | ||
|
|
ec7d3bddfc | ||
|
|
762c1ccf28 | ||
|
|
8e44c8fa06 | ||
|
|
20db102327 | ||
|
|
1643cb8165 | ||
|
|
49e623dfeb | ||
|
|
a1208974ac | ||
|
|
d611087513 | ||
|
|
ac7cb2ee19 | ||
|
|
f866572cbf | ||
|
|
4c6942f60b | ||
|
|
d939897524 | ||
|
|
66c5589fd7 | ||
|
|
379b1d611b | ||
|
|
f16221f385 | ||
|
|
9b82560270 | ||
|
|
7271af03e6 | ||
|
|
4d564bbce2 | ||
|
|
d7afdf214b | ||
|
|
18e445ea02 | ||
|
|
cb70c705a3 | ||
|
|
9a77eb9872 | ||
|
|
ec82f646a0 | ||
|
|
2f0e384240 | ||
|
|
19a1426869 | ||
|
|
cc5cd8db6b | ||
|
|
e384e2edda | ||
|
|
dca044873f | ||
|
|
8aadddcc68 | ||
|
|
2e95229c51 | ||
|
|
8a1d02c23f |
@@ -139,15 +139,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
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,10 +94,13 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
|
||||
@@ -6,7 +6,7 @@ linters:
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
@@ -9,20 +9,38 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
@@ -21,31 +21,30 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
const preview: Preview = {
|
||||
decorators: (Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
];
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
export default preview;
|
||||
|
||||
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
see also:
|
||||
|
||||
- docs/guidelines/server-architecture.md
|
||||
- docs/guidelines/go-conventions.md
|
||||
- docs/guidelines/typescript-conventions.md
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.25.7 (for backend)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
make build # Build both client and server
|
||||
make build-client # Build React/AngularJS frontend
|
||||
make build-server # Build Go binary
|
||||
make build-image # Build Docker image
|
||||
|
||||
# Development
|
||||
make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
pnpm run dev # Webpack dev server
|
||||
pnpm run build # Build frontend with webpack
|
||||
pnpm run test # Run frontend tests
|
||||
|
||||
# Testing
|
||||
make test # All tests (backend + frontend)
|
||||
make test-server # Backend tests only
|
||||
make lint # Lint all code
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Development Servers
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -108,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS {
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
ErrDBImportFailed = errors.New("importing backup failed")
|
||||
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
||||
)
|
||||
|
||||
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
service: service,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// Settings retrieve the ssl settings object.
|
||||
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
31
api/dataservices/ssl/tx.go
Normal file
31
api/dataservices/ssl/tx.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
service *Service
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
|
||||
// Settings retrieve the settings object.
|
||||
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
err := service.tx.GetObject(BucketName, []byte(key), &settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings persists a Settings object.
|
||||
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||
return service.tx.UpdateObject(BucketName, []byte(key), settings)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
|
||||
|
||||
// In this particular instance we should log a fatal error
|
||||
if m.CurrentDBEdition() != portainer.PortainerCE {
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) Stack() dataservices.StackService {
|
||||
return tx.store.StackService.Tx(tx.tx)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
@@ -35,8 +36,10 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
@@ -44,8 +47,17 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
@@ -58,7 +61,6 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
expected := ContainerStats{}
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, expected, stats)
|
||||
assert.Equal(t, expected.Running, stats.Running)
|
||||
assert.Equal(t, expected.Stopped, stats.Stopped)
|
||||
assert.Equal(t, expected.Healthy, stats.Healthy)
|
||||
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
|
||||
@@ -77,6 +77,9 @@ type (
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig portainer.HelmConfig
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
|
||||
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
|
||||
operations := map[string]func(context.Context, []string) (string, error){
|
||||
"apply": client.ApplyDynamic,
|
||||
"delete": client.Delete,
|
||||
"delete": client.DeleteDynamic,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
_, err := tx.EdgeGroup().Read(ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EdgeGroup().Exists(ID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
|
||||
|
||||
if len(payload.EdgeGroups) > 0 {
|
||||
for _, edgeGroupID := range payload.EdgeGroups {
|
||||
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
|
||||
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
|
||||
return dserrors.ErrObjectNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
|
||||
_, err := tx.EndpointGroup().Read(endpointGroupID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
|
||||
// Environment(Endpoint) group name
|
||||
Name string `example:"my-environment-group"`
|
||||
// Environment(Endpoint) group description
|
||||
Description string `example:"description"`
|
||||
Description *string `example:"description"`
|
||||
// List of environment(endpoint) identifiers that will be part of this group
|
||||
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
|
||||
// List of tag identifiers associated to the environment(endpoint) group
|
||||
TagIDs []portainer.TagID `example:"3,4"`
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
endpointGroup.Name = payload.Name
|
||||
}
|
||||
|
||||
if payload.Description != "" {
|
||||
endpointGroup.Description = payload.Description
|
||||
if payload.Description != nil {
|
||||
endpointGroup.Description = *payload.Description
|
||||
}
|
||||
|
||||
tagsChanged := false
|
||||
@@ -147,11 +149,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
|
||||
// Update flag with endpoint and continue
|
||||
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
}
|
||||
}(endpoint.ID, endpointGroup.ID)
|
||||
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
|
||||
}
|
||||
|
||||
if tagsChanged {
|
||||
// Handle associated endpoints updates
|
||||
endpointsChanged := false
|
||||
if payload.AssociatedEndpoints != nil {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
// Build a set of the new endpoint IDs for quick lookup
|
||||
newEndpointSet := make(map[portainer.EndpointID]bool)
|
||||
for _, id := range payload.AssociatedEndpoints {
|
||||
newEndpointSet[id] = true
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
endpoint := &endpoints[i]
|
||||
wasInGroup := endpoint.GroupID == endpointGroup.ID
|
||||
shouldBeInGroup := newEndpointSet[endpoint.ID]
|
||||
|
||||
if wasInGroup && !shouldBeInGroup {
|
||||
// Remove from group (move to Unassigned)
|
||||
endpoint.GroupID = portainer.EndpointGroupID(1)
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
} else if !wasInGroup && shouldBeInGroup {
|
||||
// Add to group
|
||||
endpoint.GroupID = endpointGroup.ID
|
||||
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update environment", err)
|
||||
}
|
||||
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
|
||||
}
|
||||
endpointsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
|
||||
if tagsChanged && !endpointsChanged {
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
|
||||
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
|
||||
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
||||
|
||||
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
|
||||
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to update user authorizations")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
|
||||
}
|
||||
@@ -179,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
err = tx.Tag().Update(tagID, tag)
|
||||
}
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
log.Warn().Err(err).Msg("Unable to find tag inside the database")
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
|
||||
@@ -227,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
|
||||
if endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
|
||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.37.0
|
||||
// @version 2.39.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
|
||||
@@ -2,8 +2,10 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -31,33 +33,23 @@ import (
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
|
||||
// @router /kubernetes/{id}/ingresscontrollers [get]
|
||||
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
|
||||
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
|
||||
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
|
||||
}
|
||||
|
||||
// Get endpoint from context (may have policies applied in-memory)
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
Type: "custom",
|
||||
})
|
||||
}
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses = []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
if controllers[i].ClassName != "none" {
|
||||
controllers[i].New = true
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
controllers = allowedControllers
|
||||
}
|
||||
|
||||
return response.JSON(w, controllers)
|
||||
}
|
||||
|
||||
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
|
||||
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
return httperror.InternalServerError("Unable to create Kubernetes client", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
return httperror.BadRequest("Unable to retrieve namespace from request", err)
|
||||
}
|
||||
|
||||
currentControllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
|
||||
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
currentControllers = append(currentControllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
})
|
||||
}
|
||||
|
||||
kubernetesConfig := endpoint.Kubernetes.Configuration
|
||||
existingClasses := kubernetesConfig.IngressClasses
|
||||
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
|
||||
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
|
||||
controllers := models.K8sIngressControllers{}
|
||||
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
for i := range currentControllers {
|
||||
globallyblocked := false
|
||||
currentControllers[i].Availability = true
|
||||
if currentControllers[i].ClassName != "none" {
|
||||
currentControllers[i].New = true
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: currentControllers[i].ClassName,
|
||||
Type: currentControllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
|
||||
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
|
||||
if ingressAvailabilityPerNamespace {
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
currentControllers[i].Availability = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers."
|
||||
// @router /kubernetes/{id}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
|
||||
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
|
||||
return httperror.BadRequest("Unable to retrieve environment", err)
|
||||
}
|
||||
|
||||
payload := models.K8sIngressControllers{}
|
||||
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
return httperror.InternalServerError("Unable to get privileged kube client", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
controllers, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
|
||||
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
}
|
||||
|
||||
// Add none controller if "AllowNone" is set for endpoint.
|
||||
// Use policy-applied endpoint for this check since it affects the response.
|
||||
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
|
||||
controllers = append(controllers, models.K8sIngressController{
|
||||
Name: "none",
|
||||
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
})
|
||||
}
|
||||
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: controllers[i].ClassName,
|
||||
Type: controllers[i].Type,
|
||||
}
|
||||
|
||||
// Check if the controller is already known.
|
||||
for _, existingClass := range existingClasses {
|
||||
if controllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
controllers[i].Availability = !existingClass.GloballyBlocked
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
for _, p := range payload {
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if updatedClasses[i].Name == p.ClassName {
|
||||
updatedClasses[i].GloballyBlocked = !p.Availability
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
|
||||
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
|
||||
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.NotFound("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
|
||||
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
|
||||
return httperror.BadRequest("Unable to decode and validate the request payload", err)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
PayloadLoop:
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
|
||||
return httperror.InternalServerError("Unable to fetch endpoint", err)
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
// Fetch raw endpoint and update IngressClasses within a transaction.
|
||||
// This prevents policy-applied values from being persisted to the database.
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
|
||||
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
|
||||
updatedClasses := []portainer.KubernetesIngressClassConfig{}
|
||||
|
||||
for _, p := range payload {
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
updatedClass := portainer.KubernetesIngressClassConfig{
|
||||
Name: existingClass.Name,
|
||||
Type: existingClass.Type,
|
||||
GloballyBlocked: existingClass.GloballyBlocked,
|
||||
}
|
||||
|
||||
// Handle "allow"
|
||||
if p.Availability {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
for _, ns := range updatedClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
continue PayloadLoop
|
||||
break
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
}
|
||||
|
||||
// At this point it's possible we had an existing class which was globally
|
||||
// blocked and thus not included in the payload. As a result it is not yet
|
||||
// part of updatedClasses, but we MUST include it or we would remove the
|
||||
// global block.
|
||||
for _, existingClass := range existingClasses {
|
||||
found := false
|
||||
|
||||
for _, updatedClass := range updatedClasses {
|
||||
if existingClass.Name == updatedClass.Name {
|
||||
found = true
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
}
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
|
||||
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
@@ -51,47 +52,52 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return httperror.InternalServerError("Unable to remove the registry from the database", err)
|
||||
}
|
||||
|
||||
handler.deleteKubernetesSecrets(registry)
|
||||
handler.deleteKubernetesSecrets(handler.DataStore, registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
|
||||
for endpointId, access := range registry.RegistryAccesses {
|
||||
if access.Namespaces != nil {
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
if access.Namespaces == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
if len(failedNamespaces) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedNamespaces) > 0 {
|
||||
if err := handler.PendingActionsService.Create(
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
if err := handler.PendingActionsService.Create(
|
||||
tx,
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
|
||||
// Make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
|
||||
@@ -192,28 +192,23 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
|
||||
// @router /stacks/create/swarm/repository [post]
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for name collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return stackExistsError(payload.Name)
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
return isUniqueStackName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
|
||||
_, err := tx.Stack().StackByWebhookID(webhookID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -16,7 +17,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
|
||||
}
|
||||
}
|
||||
|
||||
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
|
||||
return fmt.Errorf("failed to remove kubernetes resources: %q: %w", out, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported stack type: %v", stack.Type)
|
||||
@@ -315,7 +315,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID)
|
||||
|
||||
errors := make([]error, 0)
|
||||
var errs error
|
||||
// Delete all the stacks one by one
|
||||
for _, stack := range stacksToDelete {
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID)
|
||||
@@ -328,27 +328,27 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
err = handler.deleteStack(securityContext.UserID, &stack, endpoint)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID)
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.DataStore.Stack().Delete(stack.ID); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
if errs != nil {
|
||||
return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
|
||||
(stack.AutoUpdate == nil ||
|
||||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -747,7 +747,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, response.StatusCode)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
|
||||
@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
|
||||
}
|
||||
}
|
||||
|
||||
return service.UpdateUsersAuthorizationsTx(tx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
|
||||
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserAuthorizations will update the authorizations for the provided userid
|
||||
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
err := service.updateUserAuthorizations(tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
|
||||
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
|
||||
//
|
||||
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
|
||||
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
|
||||
func (service *Service) UpdateUsersAuthorizations() error {
|
||||
return service.UpdateUsersAuthorizationsTx(service.dataStore)
|
||||
}
|
||||
|
||||
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
|
||||
users, err := tx.User().ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
err := service.updateUserAuthorizations(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointAuthorizations, err := service.getAuthorizations(tx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.EndpointAuthorizations = endpointAuthorizations
|
||||
|
||||
return tx.User().Update(userID, user)
|
||||
}
|
||||
|
||||
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
|
||||
endpointAuthorizations := portainer.EndpointAuthorizations{}
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointGroups, err := tx.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
roles, err := tx.Role().ReadAll()
|
||||
if err != nil {
|
||||
return endpointAuthorizations, err
|
||||
}
|
||||
|
||||
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
|
||||
|
||||
return endpointAuthorizations, nil
|
||||
}
|
||||
|
||||
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
|
||||
endpointAuthorizations := make(portainer.EndpointAuthorizations)
|
||||
|
||||
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
|
||||
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
|
||||
for _, endpointGroup := range endpointGroups {
|
||||
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
|
||||
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
|
||||
if len(authorizations) > 0 {
|
||||
endpointAuthorizations[endpoint.ID] = authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return endpointAuthorizations
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := endpoint.UserAccessPolicies[user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
|
||||
policyRoles := make([]portainer.RoleID, 0)
|
||||
|
||||
for _, membership := range memberships {
|
||||
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
|
||||
if ok {
|
||||
policyRoles = append(policyRoles, policy.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
return getAuthorizationsFromRoles(policyRoles, roles)
|
||||
}
|
||||
|
||||
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
|
||||
var associatedRoles []portainer.Role
|
||||
|
||||
for _, id := range roleIdentifiers {
|
||||
for _, role := range roles {
|
||||
if role.ID == id {
|
||||
associatedRoles = append(associatedRoles, role)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizations portainer.Authorizations
|
||||
highestPriority := 0
|
||||
for _, role := range associatedRoles {
|
||||
if role.Priority > highestPriority {
|
||||
highestPriority = role.Priority
|
||||
authorizations = role.Authorizations
|
||||
}
|
||||
}
|
||||
|
||||
return authorizations
|
||||
}
|
||||
|
||||
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package errorlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Combine a slice of errors into a single error
|
||||
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
|
||||
func Combine(errorList []error) error {
|
||||
if len(errorList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errorMsg strings.Builder
|
||||
_, _ = errorMsg.WriteString("Multiple errors occurred:")
|
||||
for _, err := range errorList {
|
||||
_, _ = errorMsg.WriteString("\n" + err.Error())
|
||||
}
|
||||
|
||||
return errors.New(errorMsg.String())
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -50,7 +49,7 @@ func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole {
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for _, name := range req {
|
||||
client := kcl.cli.RbacV1().ClusterRoles()
|
||||
|
||||
@@ -70,11 +69,11 @@ func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequest
|
||||
err = client.Delete(context.Background(), name, meta.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role")
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
func isSystemClusterRole(role *rbacv1.ClusterRole) bool {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -55,7 +54,7 @@ func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) model
|
||||
// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding
|
||||
// fails, the error is logged and we continue to delete the remaining cluster role bindings.
|
||||
func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
|
||||
for _, name := range reqs {
|
||||
client := kcl.cli.RbacV1().ClusterRoleBindings()
|
||||
@@ -76,11 +75,11 @@ func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindi
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding")
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool {
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -99,7 +99,7 @@ func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
|
||||
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
|
||||
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
|
||||
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range payload {
|
||||
for _, cronJobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().CronJobs(namespace)
|
||||
@@ -110,14 +110,14 @@ func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) e
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -131,7 +131,7 @@ func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool {
|
||||
// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
@@ -151,10 +151,10 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
}
|
||||
|
||||
if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -104,7 +104,7 @@ func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||
@@ -124,9 +124,9 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -92,7 +92,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||
var errors []error
|
||||
var errs error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
client := kcl.cli.CoreV1().ServiceAccounts(namespace)
|
||||
@@ -111,12 +111,12 @@ func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDelete
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
return errs
|
||||
}
|
||||
|
||||
// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user.
|
||||
|
||||
@@ -4,6 +4,7 @@ package validation
|
||||
// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,31 +17,31 @@ const DNS1123SubdomainMaxLength int = 253
|
||||
var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
|
||||
|
||||
// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
|
||||
func IsDNS1123Subdomain(value string) []string {
|
||||
var errs []string
|
||||
func IsDNS1123Subdomain(value string) error {
|
||||
var errs error
|
||||
if len(value) > DNS1123SubdomainMaxLength {
|
||||
errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
errs = errors.Join(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
}
|
||||
if !dns1123SubdomainRegexp.MatchString(value) {
|
||||
errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
errs = errors.Join(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// MaxLenError returns a string explanation of a "string too long" validation failure.
|
||||
func MaxLenError(length int) string {
|
||||
return fmt.Sprintf("must be no more than %d characters", length)
|
||||
func MaxLenError(length int) error {
|
||||
return fmt.Errorf("must be no more than %d characters", length)
|
||||
}
|
||||
|
||||
// RegexError returns a string explanation of a regex validation failure.
|
||||
func RegexError(fmt string, examples ...string) string {
|
||||
func RegexError(fmt string, examples ...string) error {
|
||||
var s strings.Builder
|
||||
|
||||
_, _ = s.WriteString("must match the regex ")
|
||||
_, _ = s.WriteString(fmt)
|
||||
|
||||
if len(examples) == 0 {
|
||||
return s.String()
|
||||
return errors.New(s.String())
|
||||
}
|
||||
|
||||
s.WriteString(" (e.g. ")
|
||||
@@ -56,5 +57,5 @@ func RegexError(fmt string, examples ...string) string {
|
||||
|
||||
_, _ = s.WriteRune(')')
|
||||
|
||||
return s.String()
|
||||
return errors.New(s.String())
|
||||
}
|
||||
|
||||
@@ -30,8 +30,11 @@ func NewService() Service {
|
||||
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
|
||||
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
|
||||
// from the resource server and matching it with the user identifier setting.
|
||||
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := GetOAuthToken(code, configuration)
|
||||
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := GetOAuthToken(ctx, code, configuration)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving oauth token")
|
||||
|
||||
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
log.Error().Err(err).Msg("failed parsing id_token")
|
||||
}
|
||||
|
||||
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
|
||||
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving resource")
|
||||
|
||||
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
|
||||
|
||||
config := buildConfig(configuration)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return config.Exchange(ctx, unescapedCode)
|
||||
}
|
||||
|
||||
@@ -87,9 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{
|
||||
SkipClaimsValidation: true,
|
||||
}
|
||||
jwtParser := jwt.Parser{SkipClaimsValidation: true}
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
|
||||
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
|
||||
|
||||
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
|
||||
code := ""
|
||||
if _, err := GetOAuthToken(code, config); err == nil {
|
||||
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
|
||||
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
|
||||
code := validCode
|
||||
token, err := GetOAuthToken(code, config)
|
||||
token, err := GetOAuthToken(t.Context(), code, config)
|
||||
|
||||
if token == nil || err != nil {
|
||||
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
|
||||
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := authService.Authenticate(code, config); err == nil {
|
||||
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
|
||||
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
|
||||
}
|
||||
})
|
||||
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, config)
|
||||
defer srv.Close()
|
||||
|
||||
username, err := authService.Authenticate(code, config)
|
||||
username, err := authService.Authenticate(t.Context(), code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -20,38 +21,34 @@ type PendingActionsService struct {
|
||||
|
||||
var handlers = make(map[string]portainer.PendingActionHandler)
|
||||
|
||||
func NewService(
|
||||
dataStore dataservices.DataStore,
|
||||
kubeFactory *kubecli.ClientFactory,
|
||||
) *PendingActionsService {
|
||||
return &PendingActionsService{
|
||||
dataStore: dataStore,
|
||||
kubeFactory: kubeFactory,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
|
||||
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
|
||||
handlers[name] = handler
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
|
||||
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
|
||||
// Check if this pendingAction already exists
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if len(pendingActions) > 0 {
|
||||
// Same endpoint, same action and data, don't create a repeat
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
|
||||
reflect.DeepEqual(dba.ActionData, action.ActionData) {
|
||||
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
|
||||
return nil
|
||||
}
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return service.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
|
||||
@@ -65,7 +62,8 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
|
||||
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,48 +84,55 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
// creating a kube client and performing a simple operation
|
||||
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("failed to create Kubernetes client for environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = client.ServerVersion(); err != nil {
|
||||
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("endpoint_name", endpoint.Name).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("environment is not up")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == environmentID
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to read pending actions: %v", err)
|
||||
log.Warn().Err(err).Msg("failed to read pending actions")
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
|
||||
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
|
||||
}
|
||||
|
||||
for i, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == environmentID {
|
||||
if i == 0 {
|
||||
// We have at least 1 pending action for this environment
|
||||
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
|
||||
}
|
||||
for _, pendingAction := range pendingActions {
|
||||
log.Debug().
|
||||
Int("pending_action_id", int(pendingAction.ID)).
|
||||
Str("action", pendingAction.Action).
|
||||
Msg("executing pending action")
|
||||
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to execute pending action")
|
||||
|
||||
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
|
||||
err := service.executePendingAction(pendingAction, endpoint)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to execute pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to delete pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to delete pending action")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +145,8 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
handler, ok := handlers[pendingAction.Action]
|
||||
if !ok {
|
||||
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
|
||||
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
11
app/index.js
11
app/index.js
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: '<',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
app/portainer/react/views/environments.ts
Normal file
23
app/portainer/react/views/environments.ts
Normal 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;
|
||||
@@ -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))), [])
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -135,6 +135,7 @@ export function createMockEnvironment(
|
||||
allowHostNamespaceForRegularUsers: false,
|
||||
allowStackManagementForRegularUsers: false,
|
||||
allowSysctlSettingForRegularUsers: false,
|
||||
allowSecurityOptForRegularUsers: false,
|
||||
allowVolumeBrowserForRegularUsers: false,
|
||||
enableHostManagementFeatures: false,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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]`;
|
||||
@@ -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)]',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
};
|
||||
|
||||
142
app/react/components/Widget/WidgetTabs.stories.tsx
Normal file
142
app/react/components/Widget/WidgetTabs.stories.tsx
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
173
app/react/components/Widget/WidgetTabs.test.tsx
Normal file
173
app/react/components/Widget/WidgetTabs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user