Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aae5e533c6 | |||
| d54ccd5502 | |||
| 7e526c4df7 | |||
| bf56a6c913 | |||
| 6b1b6ff998 | |||
| 9183be7a8c | |||
| 7f83d15812 | |||
| f926b61978 | |||
| cc5f790f98 | |||
| 40b210a708 | |||
| 8be327f087 | |||
| f498d76c4f | |||
| a1fa77cbe4 | |||
| e11c2e9611 | |||
| 6e03a801e6 | |||
| 6024a97892 | |||
| 1549b36103 | |||
| 7bb3e0f7a6 | |||
| 49cc901dc3 | |||
| 31dd62fbcc | |||
| a7d2d134d0 | |||
| 14f25f1e88 | |||
| 69b8b8373f | |||
| 0b075e6e10 | |||
| b468160606 | |||
| a42e96b650 | |||
| 5a19f66a37 | |||
| b271026188 | |||
| d168e3c912 | |||
| 0b6ebd70e0 | |||
| 127e03552a | |||
| f2bdfc6eff | |||
| 5db67faa00 | |||
| f9dcfcb435 | |||
| 1d1bb526d0 | |||
| c8fe8ba4fd | |||
| d3692a5a5f | |||
| 3407811c28 | |||
| b71db0d1f1 | |||
| 5e5e85ff3a | |||
| 65d82e12ee | |||
| d9e730e0a5 | |||
| 21eb20b35e | |||
| f85a7ea24c | |||
| 6aacb61c87 | |||
| bb2c75ba93 | |||
| 16536c8a71 |
@@ -114,6 +114,9 @@ overrides:
|
|||||||
'@typescript-eslint/explicit-module-boundary-types': off
|
'@typescript-eslint/explicit-module-boundary-types': off
|
||||||
'@typescript-eslint/no-unused-vars': 'error'
|
'@typescript-eslint/no-unused-vars': 'error'
|
||||||
'@typescript-eslint/no-explicit-any': 'error'
|
'@typescript-eslint/no-explicit-any': 'error'
|
||||||
|
'@typescript-eslint/switch-exhaustiveness-check': 'error'
|
||||||
|
'consistent-return': 'off'
|
||||||
|
'default-case': off
|
||||||
'jsx-a11y/label-has-associated-control':
|
'jsx-a11y/label-has-associated-control':
|
||||||
- error
|
- error
|
||||||
- assert: either
|
- assert: either
|
||||||
|
|||||||
+9
-7
@@ -1,6 +1,7 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
import { StorybookConfig } from '@storybook/react-webpack5';
|
import { StorybookConfig } from '@storybook/react-webpack5';
|
||||||
|
|
||||||
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
|
||||||
import { Configuration } from 'webpack';
|
import { Configuration } from 'webpack';
|
||||||
import postcss from 'postcss';
|
import postcss from 'postcss';
|
||||||
|
|
||||||
@@ -85,12 +86,7 @@ const config: StorybookConfig = {
|
|||||||
...config,
|
...config,
|
||||||
resolve: {
|
resolve: {
|
||||||
...config.resolve,
|
...config.resolve,
|
||||||
plugins: [
|
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
|
||||||
...(config.resolve?.plugins || []),
|
|
||||||
new TsconfigPathsPlugin({
|
|
||||||
extensions: config.resolve?.extensions,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
...config.module,
|
...config.module,
|
||||||
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
|
|||||||
staticDirs: ['./public'],
|
staticDirs: ['./public'],
|
||||||
typescript: {
|
typescript: {
|
||||||
reactDocgen: 'react-docgen-typescript',
|
reactDocgen: 'react-docgen-typescript',
|
||||||
|
reactDocgenTypescriptOptions: {
|
||||||
|
compilerOptions: {
|
||||||
|
outDir: path.resolve(__dirname, '..', 'dist/public'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
framework: {
|
framework: {
|
||||||
name: '@storybook/react-webpack5',
|
name: '@storybook/react-webpack5',
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
docs: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ see also:
|
|||||||
## Package Manager
|
## Package Manager
|
||||||
|
|
||||||
- **PNPM** 10+ (for frontend)
|
- **PNPM** 10+ (for frontend)
|
||||||
- **Go** 1.25.7 (for backend)
|
- **Go** 1.25.8 (for backend)
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
@@ -27,9 +27,13 @@ make dev # Run both in dev mode
|
|||||||
make dev-client # Start webpack-dev-server (port 8999)
|
make dev-client # Start webpack-dev-server (port 8999)
|
||||||
make dev-server # Run containerized Go server
|
make dev-server # Run containerized Go server
|
||||||
|
|
||||||
pnpm run dev # Webpack dev server
|
# Frontend
|
||||||
pnpm run build # Build frontend with webpack
|
pnpm dev # Webpack dev server
|
||||||
pnpm run test # Run frontend tests
|
pnpm build # Build frontend with webpack
|
||||||
|
pnpm typecheck # Run typecheck for frontend (with tsc)
|
||||||
|
pnpm lint # lint frontend (with eslint)
|
||||||
|
pnpm test # test frontend (with vitest)
|
||||||
|
pnpm format # format frontend (with prettier)
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
make test # All tests (backend + frontend)
|
make test # All tests (backend + frontend)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
|||||||
TAG=local
|
TAG=local
|
||||||
|
|
||||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
||||||
GOTESTSUM=go run gotest.tools/gotestsum@latest
|
GOTESTSUM_VERSION?=v1.13.0
|
||||||
|
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
|
||||||
|
|
||||||
# Don't change anything below this line unless you know what you're doing
|
# Don't change anything below this line unless you know what you're doing
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
|
|||||||
test-client: ## Run client tests
|
test-client: ## Run client tests
|
||||||
pnpm run test $(ARGS) --coverage
|
pnpm run test $(ARGS) --coverage
|
||||||
|
|
||||||
|
TEST_PACKAGES?=./...
|
||||||
|
|
||||||
test-server: ## Run server tests
|
test-server: ## Run server tests
|
||||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
|
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
|
||||||
|
|
||||||
##@ Dev
|
##@ Dev
|
||||||
.PHONY: dev dev-client dev-server
|
.PHONY: dev dev-client dev-server
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
|
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
|
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
|||||||
flags.TLSKey = tlsKeyFlag.String()
|
flags.TLSKey = tlsKeyFlag.String()
|
||||||
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
||||||
|
|
||||||
flags.KubectlShellImage = kingpin.Flag(
|
var hasKubectlShellImageFlag bool
|
||||||
|
kubectlShellImageFlag := kingpin.Flag(
|
||||||
"kubectl-shell-image",
|
"kubectl-shell-image",
|
||||||
"Kubectl shell image",
|
"Kubectl shell image",
|
||||||
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
|
).Envar(portainer.KubectlShellImageEnvVar).
|
||||||
|
Default(portainer.DefaultKubectlShellImage).
|
||||||
|
IsSetByUser(&hasKubectlShellImageFlag)
|
||||||
|
flags.KubectlShellImage = kubectlShellImageFlag.String()
|
||||||
|
|
||||||
kingpin.Parse()
|
kingpin.Parse()
|
||||||
|
|
||||||
|
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
|
||||||
|
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
|
||||||
|
|
||||||
if !filepath.IsAbs(*flags.Assets) {
|
if !filepath.IsAbs(*flags.Assets) {
|
||||||
ex, err := os.Executable()
|
ex, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
zerolog "github.com/rs/zerolog/log"
|
zerolog "github.com/rs/zerolog/log"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
|
|||||||
require.True(t, *opts.EnableEdgeComputeFeatures)
|
require.True(t, *opts.EnableEdgeComputeFeatures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseKubectlShellImageFlag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
envVars map[string]string
|
||||||
|
expectedKubectlShellImageSet bool
|
||||||
|
expectedKubectlShellFlag string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no flag, no env var",
|
||||||
|
expectedKubectlShellImageSet: false,
|
||||||
|
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit flag",
|
||||||
|
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||||
|
expectedKubectlShellImageSet: true,
|
||||||
|
expectedKubectlShellFlag: "myimage:v2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env var",
|
||||||
|
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||||
|
expectedKubectlShellImageSet: true,
|
||||||
|
expectedKubectlShellFlag: "myimage:v3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both env var and flag set",
|
||||||
|
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||||
|
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||||
|
expectedKubectlShellImageSet: true,
|
||||||
|
expectedKubectlShellFlag: "myimage:v2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.args == nil {
|
||||||
|
tc.args = []string{"portainer"}
|
||||||
|
}
|
||||||
|
setOsArgs(t, tc.args)
|
||||||
|
|
||||||
|
for k, v := range tc.envVars {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := Service{}.ParseFlags("test-version")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
|
||||||
|
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseTLSFlags(t *testing.T) {
|
func TestParseTLSFlags(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
|||||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||||
|
|
||||||
|
if flags.KubectlShellImageSet {
|
||||||
|
settings.KubectlShellImage = *flags.KubectlShellImage
|
||||||
|
}
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
settings.BlackListedLabels = *flags.Labels
|
settings.BlackListedLabels = *flags.Labels
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -12,7 +15,7 @@ import (
|
|||||||
const secretFileName = "secret.txt"
|
const secretFileName = "secret.txt"
|
||||||
|
|
||||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||||
err := os.WriteFile(secretPath, []byte(password), 0600)
|
err := os.WriteFile(secretPath, []byte(password), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return secretPath
|
return secretPath
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
|
|||||||
require.Len(t, encryptionKey, 32)
|
require.Len(t, encryptionKey, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
|
||||||
|
const existingImage = "existing-image:v1"
|
||||||
|
const newImage = "new-image:v2"
|
||||||
|
|
||||||
|
emptyString := ""
|
||||||
|
falseBool := false
|
||||||
|
var emptyLabels []portainer.Pair
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
imageSet bool
|
||||||
|
flagImage string
|
||||||
|
expectedKubectlShellImage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "flag not set — DB image unchanged",
|
||||||
|
imageSet: false,
|
||||||
|
flagImage: portainer.DefaultKubectlShellImage,
|
||||||
|
expectedKubectlShellImage: existingImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flag set — DB image updated",
|
||||||
|
imageSet: true,
|
||||||
|
flagImage: newImage,
|
||||||
|
expectedKubectlShellImage: newImage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
store := testhelpers.NewDatastore(
|
||||||
|
testhelpers.WithSettingsService(&portainer.Settings{
|
||||||
|
KubectlShellImage: existingImage,
|
||||||
|
}),
|
||||||
|
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
|
||||||
|
)
|
||||||
|
|
||||||
|
flags := &portainer.CLIFlags{
|
||||||
|
SnapshotInterval: &emptyString,
|
||||||
|
Logo: &emptyString,
|
||||||
|
EnableEdgeComputeFeatures: &falseBool,
|
||||||
|
Templates: &emptyString,
|
||||||
|
Labels: &emptyLabels,
|
||||||
|
HTTPDisabled: &falseBool,
|
||||||
|
HTTPEnabled: &falseBool,
|
||||||
|
}
|
||||||
|
flags.KubectlShellImage = &tc.flagImage
|
||||||
|
flags.KubectlShellImageSet = tc.imageSet
|
||||||
|
|
||||||
|
err := updateSettingsFromFlags(store, flags)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
settings, err := store.Settings().Settings()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDBSecretPath(t *testing.T) {
|
func TestDBSecretPath(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
keyFilenameFlag string
|
keyFilenameFlag string
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
|
|||||||
KubectlShellImage: *store.flags.KubectlShellImage,
|
KubectlShellImage: *store.flags.KubectlShellImage,
|
||||||
|
|
||||||
IsDockerDesktopExtension: isDDExtention,
|
IsDockerDesktopExtension: isDDExtention,
|
||||||
|
EnforceEdgeID: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||||
|
|||||||
@@ -613,7 +613,7 @@
|
|||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
|
"KubectlShellImage": "portainer/kubectl-shell:2.39.2",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
@@ -942,7 +942,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.39.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
},
|
},
|
||||||
"webhooks": null
|
"webhooks": null
|
||||||
}
|
}
|
||||||
+11
-15
@@ -66,7 +66,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
|
|||||||
EnvFilePath: envFilePath,
|
EnvFilePath: envFilePath,
|
||||||
Host: url,
|
Host: url,
|
||||||
ProjectName: stack.Name,
|
ProjectName: stack.Name,
|
||||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||||
},
|
},
|
||||||
ForceRecreate: options.ForceRecreate,
|
ForceRecreate: options.ForceRecreate,
|
||||||
AbortOnContainerExit: options.AbortOnContainerExit,
|
AbortOnContainerExit: options.AbortOnContainerExit,
|
||||||
@@ -97,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
|
|||||||
EnvFilePath: envFilePath,
|
EnvFilePath: envFilePath,
|
||||||
Host: url,
|
Host: url,
|
||||||
ProjectName: stack.Name,
|
ProjectName: stack.Name,
|
||||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||||
},
|
},
|
||||||
Remove: options.Remove,
|
Remove: options.Remove,
|
||||||
Args: options.Args,
|
Args: options.Args,
|
||||||
@@ -146,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
|
|||||||
EnvFilePath: envFilePath,
|
EnvFilePath: envFilePath,
|
||||||
Host: url,
|
Host: url,
|
||||||
ProjectName: stack.Name,
|
ProjectName: stack.Name,
|
||||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||||
})
|
})
|
||||||
return errors.Wrap(err, "failed to pull images of the stack")
|
return errors.Wrap(err, "failed to pull images of the stack")
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,12 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
|
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
|
||||||
|
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
|
||||||
|
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
|
||||||
|
// intentionally performs no DB writes to avoid write-lock contention when called inside
|
||||||
|
// an active BoltDB write transaction.
|
||||||
|
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
|
||||||
var authConfigs []types.AuthConfig
|
var authConfigs []types.AuthConfig
|
||||||
|
|
||||||
for _, r := range registries {
|
for _, r := range registries {
|
||||||
@@ -242,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
|
|||||||
if r.Authentication {
|
if r.Authentication {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
|
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -254,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
|
|||||||
return authConfigs
|
return authConfigs
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
|
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
|
||||||
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
|
|
||||||
log.Warn().
|
|
||||||
Err(err).
|
|
||||||
Str("RegistryName", registry.Name).
|
|
||||||
Msg("Failed to validate registry token. Skip logging with this registry.")
|
|
||||||
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
username, password, err := registryutils.GetRegEffectiveCredential(registry)
|
username, password, err := registryutils.GetRegEffectiveCredential(registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().
|
log.Warn().
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
@@ -94,3 +95,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
|
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("returns empty slice for empty input", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
|
||||||
|
require.Nil(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registries := []portainer.Registry{
|
||||||
|
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
|
||||||
|
}
|
||||||
|
result := portainerRegistriesToAuthConfigs(registries)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.Equal(t, "registry.example.com", result[0].ServerAddress)
|
||||||
|
require.Equal(t, "user", result[0].Username)
|
||||||
|
require.Equal(t, "pass", result[0].Password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registries := []portainer.Registry{
|
||||||
|
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
|
||||||
|
}
|
||||||
|
result := portainerRegistriesToAuthConfigs(registries)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.Equal(t, "user", result[0].Username)
|
||||||
|
require.Equal(t, "pass", result[0].Password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registries := []portainer.Registry{
|
||||||
|
{
|
||||||
|
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||||
|
Username: "AKIAIOSFODNN7EXAMPLE",
|
||||||
|
Password: "secretkey",
|
||||||
|
Authentication: true,
|
||||||
|
Type: portainer.EcrRegistry,
|
||||||
|
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||||
|
AccessToken: "AWS:ecr-password",
|
||||||
|
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := portainerRegistriesToAuthConfigs(registries)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.Equal(t, "AWS", result[0].Username)
|
||||||
|
require.Equal(t, "ecr-password", result[0].Password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registries := []portainer.Registry{
|
||||||
|
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
|
||||||
|
{
|
||||||
|
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||||
|
Authentication: true,
|
||||||
|
Type: portainer.EcrRegistry,
|
||||||
|
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||||
|
AccessToken: "no-colon-token",
|
||||||
|
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := portainerRegistriesToAuthConfigs(registries)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
require.Equal(t, "valid.example.com", result[0].ServerAddress)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
|||||||
|
|
||||||
for _, registry := range registries {
|
for _, registry := range registries {
|
||||||
if registry.Authentication {
|
if registry.Authentication {
|
||||||
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, ®istry)
|
username, password, err := getEffectiveRegUsernamePassword(®istry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-20
@@ -3,23 +3,42 @@ package git
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5"
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"github.com/go-git/go-git/v5/config"
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
|
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
|
||||||
"github.com/go-git/go-git/v5/storage/memory"
|
"github.com/go-git/go-git/v5/storage/memory"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
|
||||||
|
// symlink traversal attacks from untrusted git repositories
|
||||||
|
type noSymlinkFS struct {
|
||||||
|
billy.Filesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs noSymlinkFS) Symlink(_, _ string) error {
|
||||||
|
return gittypes.ErrSymlinkDetected
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoSymlinkFS wraps fs and rejects any symlink creation
|
||||||
|
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
|
||||||
|
return noSymlinkFS{fs}
|
||||||
|
}
|
||||||
|
|
||||||
type gitClient struct {
|
type gitClient struct {
|
||||||
preserveGitDirectory bool
|
preserveGitDirectory bool
|
||||||
}
|
}
|
||||||
@@ -30,8 +49,33 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||||
|
wt := NewNoSymlinkFS(osfs.New(dst))
|
||||||
|
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
|
||||||
|
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
|
||||||
|
|
||||||
|
_, err := git.CloneContext(ctx, storer, wt, opt)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "authentication required" {
|
||||||
|
return gittypes.ErrAuthenticationFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "failed to clone git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.preserveGitDirectory {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to remove .git directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
|
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
|
||||||
gitOptions := git.CloneOptions{
|
gitOptions := &git.CloneOptions{
|
||||||
URL: opt.repositoryUrl,
|
URL: opt.repositoryUrl,
|
||||||
Depth: opt.depth,
|
Depth: opt.depth,
|
||||||
InsecureSkipTLS: opt.tlsSkipVerify,
|
InsecureSkipTLS: opt.tlsSkipVerify,
|
||||||
@@ -43,23 +87,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
|
|||||||
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
|
return c.Download(ctx, dst, gitOptions)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "authentication required" {
|
|
||||||
return gittypes.ErrAuthenticationFailure
|
|
||||||
}
|
|
||||||
return errors.Wrap(err, "failed to clone git repository")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.preserveGitDirectory {
|
|
||||||
err := os.RemoveAll(filepath.Join(dst, ".git"))
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
|
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
|
||||||
@@ -78,6 +106,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
|
|||||||
if err.Error() == "authentication required" {
|
if err.Error() == "authentication required" {
|
||||||
return "", gittypes.ErrAuthenticationFailure
|
return "", gittypes.ErrAuthenticationFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errors.Wrap(err, "failed to list repository refs")
|
return "", errors.Wrap(err, "failed to list repository refs")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +188,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
|
|||||||
if ref.Name().String() == "HEAD" {
|
if ref.Name().String() == "HEAD" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = append(ret, ref.Name().String())
|
ret = append(ret, ref.Name().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
|
|||||||
} else if errMsg == "authentication required" {
|
} else if errMsg == "authentication required" {
|
||||||
return gittypes.ErrAuthenticationFailure
|
return gittypes.ErrAuthenticationFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-3
@@ -3,13 +3,17 @@ package git
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/archive"
|
"github.com/portainer/portainer/api/archive"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -18,7 +22,7 @@ import (
|
|||||||
|
|
||||||
func setup(t *testing.T) string {
|
func setup(t *testing.T) string {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bareRepoDir := filepath.Join(dir, "test-clone.git")
|
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
|
||||||
|
|
||||||
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
|
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,7 +57,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
|||||||
t.Logf("Cloning into %s", dir)
|
t.Logf("Cloning into %s", dir)
|
||||||
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_cloneRepository(t *testing.T) {
|
func Test_cloneRepository(t *testing.T) {
|
||||||
@@ -146,6 +150,112 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_noSymlinkFS_Symlink(t *testing.T) {
|
||||||
|
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
|
||||||
|
err := fs.Symlink("../../../etc/passwd", "evil-link")
|
||||||
|
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fs := NewNoSymlinkFS(osfs.New(dir))
|
||||||
|
|
||||||
|
f, err := fs.Create("test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = f.Write([]byte("hello"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = f.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info, err := fs.Stat("test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test.txt", info.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBareRepoWithSymlink(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
|
||||||
|
|
||||||
|
repo, err := git.PlainInit(bareDir, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
storer := repo.Storer
|
||||||
|
|
||||||
|
fileBlob := &plumbing.MemoryObject{}
|
||||||
|
fileBlob.SetType(plumbing.BlobObject)
|
||||||
|
|
||||||
|
_, err = fileBlob.Write([]byte("hello world\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileHash, err := storer.SetEncodedObject(fileBlob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
symlinkBlob := &plumbing.MemoryObject{}
|
||||||
|
symlinkBlob.SetType(plumbing.BlobObject)
|
||||||
|
|
||||||
|
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tree := &object.Tree{
|
||||||
|
Entries: []object.TreeEntry{
|
||||||
|
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
|
||||||
|
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
treeObj := &plumbing.MemoryObject{}
|
||||||
|
|
||||||
|
err = tree.Encode(treeObj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
treeHash, err := storer.SetEncodedObject(treeObj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
|
||||||
|
commit := &object.Commit{
|
||||||
|
Message: "add symlink",
|
||||||
|
Author: sig,
|
||||||
|
Committer: sig,
|
||||||
|
TreeHash: treeHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
commitObj := &plumbing.MemoryObject{}
|
||||||
|
|
||||||
|
err = commit.Encode(commitObj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
commitHash, err := storer.SetEncodedObject(commitObj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return bareDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Download_RejectsSymlink(t *testing.T) {
|
||||||
|
client := NewGitClient(false)
|
||||||
|
repoURL := createBareRepoWithSymlink(t)
|
||||||
|
|
||||||
|
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
|
||||||
|
URL: repoURL,
|
||||||
|
Depth: 1,
|
||||||
|
SingleBranch: true,
|
||||||
|
Tags: git.NoTags,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
|
||||||
|
}
|
||||||
|
|
||||||
func Test_listRefsPrivateRepository(t *testing.T) {
|
func Test_listRefsPrivateRepository(t *testing.T) {
|
||||||
ensureIntegrationTest(t)
|
ensureIntegrationTest(t)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
|
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
|
||||||
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
|
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
|
||||||
|
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
|
||||||
)
|
)
|
||||||
|
|
||||||
type GitCredentialAuthType int
|
type GitCredentialAuthType int
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package customtemplates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
|
|||||||
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
return httperror.BadRequest("Invalid custom template identifier route variable", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
var customTemplate *portainer.CustomTemplate
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
var err error
|
||||||
} else if err != nil {
|
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
if tx.IsErrObjectNotFound(err) {
|
||||||
|
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||||
|
} else if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canEdit := userCanEditTemplate(customTemplate, securityContext)
|
||||||
|
hasAccess := false
|
||||||
|
|
||||||
|
if resourceControl != nil {
|
||||||
|
customTemplate.ResourceControl = resourceControl
|
||||||
|
|
||||||
|
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
|
||||||
|
return m.TeamID
|
||||||
|
})
|
||||||
|
|
||||||
|
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canEdit || hasAccess {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||||
|
}); err != nil {
|
||||||
|
return response.TxErrorResponse(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entryPath := customTemplate.EntryPoint
|
entryPath := customTemplate.EntryPoint
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package customtemplates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomTemplateFile(t *testing.T) {
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
require.NotNil(t, ds)
|
||||||
|
|
||||||
|
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
templateContent := "some template content"
|
||||||
|
templateEntrypoint := "entrypoint"
|
||||||
|
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||||
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||||
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
|
||||||
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
|
||||||
|
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{
|
||||||
|
2: portainer.AccessPolicy{RoleID: 0},
|
||||||
|
3: portainer.AccessPolicy{RoleID: 0},
|
||||||
|
}}))
|
||||||
|
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
|
||||||
|
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
|
||||||
|
|
||||||
|
// template 1
|
||||||
|
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||||
|
|
||||||
|
// template 2
|
||||||
|
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
|
||||||
|
|
||||||
|
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
|
||||||
|
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
|
||||||
|
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
|
||||||
|
}))
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||||
|
|
||||||
|
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
|
||||||
|
r = mux.SetURLVars(r, map[string]string{"id": templateID})
|
||||||
|
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
return rr, handler.customTemplateFile(rr, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unknown id should get not found error", func(t *testing.T) {
|
||||||
|
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusNotFound, r.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin should access adminonly template", func(t *testing.T) {
|
||||||
|
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||||
|
require.Nil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||||
|
var res struct{ FileContent string }
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||||
|
require.Equal(t, templateContent, res.FileContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("std should not access adminonly template", func(t *testing.T) {
|
||||||
|
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("std should access template via direct user access", func(t *testing.T) {
|
||||||
|
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
|
||||||
|
require.Nil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||||
|
var res struct{ FileContent string }
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||||
|
require.Equal(t, templateContent, res.FileContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("std should access template via team access", func(t *testing.T) {
|
||||||
|
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
|
||||||
|
require.Nil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||||
|
var res struct{ FileContent string }
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
|
||||||
|
require.Equal(t, templateContent, res.FileContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("std should not access template without access", func(t *testing.T) {
|
||||||
|
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
|
|||||||
var customTemplate *portainer.CustomTemplate
|
var customTemplate *portainer.CustomTemplate
|
||||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
if tx.IsErrObjectNotFound(err) {
|
||||||
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
|
|||||||
_, ds := datastore.MustNewTestStore(t, true, false)
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
require.NotNil(t, ds)
|
require.NotNil(t, ds)
|
||||||
|
|
||||||
|
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
|
||||||
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
|
||||||
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
|
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
|
||||||
|
|
||||||
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
|
||||||
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package containers
|
package containers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
@@ -46,7 +47,7 @@ func (handler *Handler) recreate(w http.ResponseWriter, r *http.Request) *httper
|
|||||||
|
|
||||||
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||||
|
|
||||||
newContainer, err := handler.containerService.Recreate(r.Context(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
|
newContainer, err := handler.containerService.Recreate(context.TODO(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Error recreating container", err)
|
return httperror.InternalServerError("Error recreating container", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type StackViewModel struct {
|
|||||||
Name string
|
Name string
|
||||||
IsExternal bool
|
IsExternal bool
|
||||||
Type portainer.StackType
|
Type portainer.StackType
|
||||||
|
Labels map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
|
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
|
||||||
@@ -56,6 +57,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
|||||||
Name: name,
|
Name: name,
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
Type: portainer.DockerComposeStack,
|
Type: portainer.DockerComposeStack,
|
||||||
|
Labels: container.Labels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
|||||||
Name: name,
|
Name: name,
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
Type: portainer.DockerSwarmStack,
|
Type: portainer.DockerSwarmStack,
|
||||||
|
Labels: service.Spec.Labels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,10 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
|
|||||||
|
|
||||||
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
|
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
|
||||||
func(item StackViewModel) (*portainer.ResourceControl, error) {
|
func(item StackViewModel) (*portainer.ResourceControl, error) {
|
||||||
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
|
if item.InternalStack != nil {
|
||||||
|
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
|
||||||
|
}
|
||||||
|
return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
dockerconsts "github.com/portainer/portainer/api/docker/consts"
|
"github.com/portainer/portainer/api/docker/consts"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
|||||||
containers := []types.Container{
|
containers := []types.Container{
|
||||||
{
|
{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
dockerconsts.ComposeStackNameLabel: "stack1",
|
consts.ComposeStackNameLabel: "stack1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
dockerconsts.ComposeStackNameLabel: "stack2",
|
consts.ComposeStackNameLabel: "stack2",
|
||||||
|
"io.portainer.accesscontrol.public": "true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
|||||||
Spec: swarm.ServiceSpec{
|
Spec: swarm.ServiceSpec{
|
||||||
Annotations: swarm.Annotations{
|
Annotations: swarm.Annotations{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
dockerconsts.SwarmStackNameLabel: "stack3",
|
consts.SwarmStackNameLabel: "stack3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -65,14 +66,16 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
|||||||
is.NoError(tx.Stack().Create(&stack1))
|
is.NoError(tx.Stack().Create(&stack1))
|
||||||
is.NoError(tx.Stack().Create(&portainer.Stack{
|
is.NoError(tx.Stack().Create(&portainer.Stack{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Name: "stack2",
|
Name: "stack2", // stack 2 on env 2
|
||||||
EndpointID: 2,
|
EndpointID: 2,
|
||||||
Type: portainer.DockerSwarmStack,
|
Type: portainer.DockerSwarmStack,
|
||||||
}))
|
}))
|
||||||
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
|
||||||
|
is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}))
|
||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// testing admin user
|
||||||
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
|
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
|
|||||||
Name: "stack2",
|
Name: "stack2",
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
Type: portainer.DockerComposeStack,
|
Type: portainer.DockerComposeStack,
|
||||||
|
Labels: map[string]string{
|
||||||
|
consts.ComposeStackNameLabel: "stack2",
|
||||||
|
"io.portainer.accesscontrol.public": "true",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "stack3",
|
Name: "stack3",
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
Type: portainer.DockerSwarmStack,
|
Type: portainer.DockerSwarmStack,
|
||||||
|
Labels: map[string]string{
|
||||||
|
consts.SwarmStackNameLabel: "stack3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, expectedStacks, stacksList)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// testing standard user
|
||||||
|
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: false,
|
||||||
|
UserID: 2,
|
||||||
|
}, environment.ID, containers, services)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, stacksList, 1)
|
||||||
|
|
||||||
|
expectedStacks := []StackViewModel{
|
||||||
|
{
|
||||||
|
Name: "stack2",
|
||||||
|
IsExternal: true,
|
||||||
|
Type: portainer.DockerComposeStack,
|
||||||
|
Labels: map[string]string{
|
||||||
|
consts.ComposeStackNameLabel: "stack2",
|
||||||
|
"io.portainer.accesscontrol.public": "true",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const (
|
|||||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||||
|
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
|
||||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
|
|||||||
edgeStackId portainer.EdgeStackID
|
edgeStackId portainer.EdgeStackID
|
||||||
edgeStackStatus *portainer.EdgeStackStatusType
|
edgeStackStatus *portainer.EdgeStackStatusType
|
||||||
excludeIds []portainer.EndpointID
|
excludeIds []portainer.EndpointID
|
||||||
|
excludeGroupIds []portainer.EndpointGroupID
|
||||||
edgeGroupIds []portainer.EdgeGroupID
|
edgeGroupIds []portainer.EdgeGroupID
|
||||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
|||||||
return EnvironmentsQuery{}, err
|
return EnvironmentsQuery{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
|
||||||
|
if err != nil {
|
||||||
|
return EnvironmentsQuery{}, err
|
||||||
|
}
|
||||||
|
|
||||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EnvironmentsQuery{}, err
|
return EnvironmentsQuery{}, err
|
||||||
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
|||||||
tagIds: tagIDs,
|
tagIds: tagIDs,
|
||||||
endpointIds: endpointIDs,
|
endpointIds: endpointIDs,
|
||||||
excludeIds: excludeIDs,
|
excludeIds: excludeIDs,
|
||||||
|
excludeGroupIds: excludeGroupIDs,
|
||||||
tagsPartialMatch: tagsPartialMatch,
|
tagsPartialMatch: tagsPartialMatch,
|
||||||
groupIds: groupIDs,
|
groupIds: groupIDs,
|
||||||
status: status,
|
status: status,
|
||||||
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(query.excludeGroupIds) > 0 {
|
||||||
|
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||||
|
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if len(query.groupIds) > 0 {
|
if len(query.groupIds) > 0 {
|
||||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
|
|||||||
runTests(tests, t, handler, environments)
|
runTests(tests, t, handler, environments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_Filter_excludeGroupIDs(t *testing.T) {
|
||||||
|
groupA := portainer.EndpointGroupID(10)
|
||||||
|
groupB := portainer.EndpointGroupID(20)
|
||||||
|
groupC := portainer.EndpointGroupID(30)
|
||||||
|
|
||||||
|
endpoints := []portainer.Endpoint{
|
||||||
|
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||||
|
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
|
||||||
|
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||||
|
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
|
||||||
|
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := setupFilterTest(t, endpoints)
|
||||||
|
|
||||||
|
tests := []filterTest{
|
||||||
|
{
|
||||||
|
title: "should exclude endpoints in groupA",
|
||||||
|
expected: []portainer.EndpointID{3, 4, 5},
|
||||||
|
query: EnvironmentsQuery{
|
||||||
|
excludeGroupIds: []portainer.EndpointGroupID{groupA},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "should exclude endpoints in groupA and groupB",
|
||||||
|
expected: []portainer.EndpointID{5},
|
||||||
|
query: EnvironmentsQuery{
|
||||||
|
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "should return all endpoints when excludeGroupIds is empty",
|
||||||
|
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
|
||||||
|
query: EnvironmentsQuery{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests(tests, t, handler, endpoints)
|
||||||
|
}
|
||||||
|
|
||||||
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
|
||||||
n := 10000
|
n := 10000
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
|||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/agent_versions",
|
h.Handle("/endpoints/agent_versions",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
|
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
|
||||||
|
|
||||||
h.Handle("/endpoints/{id}",
|
h.Handle("/endpoints/{id}",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.39.0
|
// @version 2.39.2
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||||||
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
||||||
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
|
||||||
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
|
||||||
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
||||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
|
||||||
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
|
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
|
||||||
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
|
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
// @produce json
|
// @produce json
|
||||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||||
// @param nodeName query string false "node name"
|
// @param nodeName query string false "node name"
|
||||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
|
||||||
// @success 200
|
// @success 200
|
||||||
// @failure 400
|
// @failure 400
|
||||||
// @failure 403
|
// @failure 403
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
|
|||||||
// @produce json
|
// @produce json
|
||||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||||
// @param nodeName query string false "node name"
|
// @param nodeName query string false "node name"
|
||||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
|
||||||
// @success 200
|
// @success 200
|
||||||
// @failure 400
|
// @failure 400
|
||||||
// @failure 409
|
// @failure 409
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import (
|
|||||||
// @param podName query string true "name of the pod containing the container"
|
// @param podName query string true "name of the pod containing the container"
|
||||||
// @param containerName query string true "name of the container"
|
// @param containerName query string true "name of the container"
|
||||||
// @param command query string true "command to execute in the container"
|
// @param command query string true "command to execute in the container"
|
||||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
|
||||||
// @success 200
|
// @success 200
|
||||||
// @failure 400
|
// @failure 400
|
||||||
// @failure 403
|
// @failure 403
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
// @accept json
|
// @accept json
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
|
||||||
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
|
|
||||||
// @success 200 "Success"
|
// @success 200 "Success"
|
||||||
// @failure 400 "Invalid request"
|
// @failure 400 "Invalid request"
|
||||||
// @failure 403 "Permission denied"
|
// @failure 403 "Permission denied"
|
||||||
|
|||||||
@@ -170,18 +170,23 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
|
|||||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||||
type PartialContainer struct {
|
type PartialContainer struct {
|
||||||
HostConfig struct {
|
HostConfig struct {
|
||||||
Privileged bool `json:"Privileged"`
|
Privileged bool `json:"Privileged"`
|
||||||
PidMode string `json:"PidMode"`
|
PidMode string `json:"PidMode"`
|
||||||
Devices []any `json:"Devices"`
|
Devices []any `json:"Devices"`
|
||||||
Sysctls map[string]any `json:"Sysctls"`
|
Sysctls map[string]any `json:"Sysctls"`
|
||||||
CapAdd []string `json:"CapAdd"`
|
SecurityOpt []string `json:"SecurityOpt"`
|
||||||
CapDrop []string `json:"CapDrop"`
|
CapAdd []string `json:"CapAdd"`
|
||||||
Binds []string `json:"Binds"`
|
CapDrop []string `json:"CapDrop"`
|
||||||
|
Binds []string `json:"Binds"`
|
||||||
|
Mounts []struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
} `json:"Mounts"`
|
||||||
} `json:"HostConfig"`
|
} `json:"HostConfig"`
|
||||||
}
|
}
|
||||||
|
|
||||||
forbiddenResponse := &http.Response{
|
forbiddenResponse := &http.Response{
|
||||||
StatusCode: http.StatusForbidden,
|
StatusCode: http.StatusForbidden,
|
||||||
|
Body: http.NoBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenData, err := security.RetrieveTokenData(request)
|
tokenData, err := security.RetrieveTokenData(request)
|
||||||
@@ -230,7 +235,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
|||||||
return nil, ErrContainerCapabilitiesForbidden
|
return nil, ErrContainerCapabilitiesForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
|
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
|
||||||
for _, bind := range partialContainer.HostConfig.Binds {
|
for _, bind := range partialContainer.HostConfig.Binds {
|
||||||
if strings.HasPrefix(bind, "/") {
|
if strings.HasPrefix(bind, "/") {
|
||||||
return forbiddenResponse, ErrBindMountsForbidden
|
return forbiddenResponse, ErrBindMountsForbidden
|
||||||
@@ -238,6 +243,14 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
|
||||||
|
for _, mount := range partialContainer.HostConfig.Mounts {
|
||||||
|
if mount.Type == "bind" {
|
||||||
|
return forbiddenResponse, ErrBindMountsForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
|||||||
|
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
|
||||||
|
type PartialContainerUpdate struct {
|
||||||
|
Devices []any `json:"Devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
forbiddenResponse := &http.Response{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAdminOrEndpointAdmin {
|
||||||
|
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
partialUpdate := &PartialContainerUpdate{}
|
||||||
|
if err := json.Unmarshal(body, partialUpdate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
|
||||||
|
return forbiddenResponse, ErrDeviceMappingForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||||
|
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
|
||||||
|
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
err := tx.User().Create(&admin)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.User().Create(®ularUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.Endpoint().Create(&portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
Name: "test",
|
||||||
|
SecuritySettings: portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
srv, version := mockDockerAPIServer(t, RoutesDefinition{
|
||||||
|
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
transport := &Transport{
|
||||||
|
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
|
||||||
|
dataStore: ds,
|
||||||
|
HTTPTransport: &http.Transport{},
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
|
||||||
|
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
|
||||||
|
|
||||||
|
makeRequest := func(token portainer.TokenData, body any) *http.Request {
|
||||||
|
bodyBytes, err := json.Marshal(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
|
||||||
|
req = req.WithContext(security.StoreTokenData(req, &token))
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypasses security checks
|
||||||
|
req := makeRequest(adminToken, map[string]any{
|
||||||
|
"HostConfig": map[string]any{
|
||||||
|
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// HostConfig.Binds with an absolute path is blocked for regular users
|
||||||
|
req = makeRequest(userToken, map[string]any{
|
||||||
|
"HostConfig": map[string]any{
|
||||||
|
"Binds": []string{"/:/host:ro"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// HostConfig.Mounts with type bind is blocked for regular users
|
||||||
|
req = makeRequest(userToken, map[string]any{
|
||||||
|
"HostConfig": map[string]any{
|
||||||
|
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// HostConfig.Mounts with a non-bind type is allowed for regular users
|
||||||
|
req = makeRequest(userToken, map[string]any{
|
||||||
|
"HostConfig": map[string]any{
|
||||||
|
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
@@ -18,6 +18,70 @@ import (
|
|||||||
|
|
||||||
const serviceObjectIdentifier = "ID"
|
const serviceObjectIdentifier = "ID"
|
||||||
|
|
||||||
|
type partialServiceSpec struct {
|
||||||
|
TaskTemplate struct {
|
||||||
|
ContainerSpec struct {
|
||||||
|
CapabilityAdd []string `json:"CapabilityAdd"`
|
||||||
|
CapabilityDrop []string `json:"CapabilityDrop"`
|
||||||
|
Sysctls map[string]any `json:"Sysctls"`
|
||||||
|
Privileges *struct {
|
||||||
|
Seccomp *struct{ Mode string } `json:"Seccomp"`
|
||||||
|
AppArmor *struct{ Mode string } `json:"AppArmor"`
|
||||||
|
} `json:"Privileges"`
|
||||||
|
Mounts []struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
VolumeOptions *struct {
|
||||||
|
DriverConfig *struct {
|
||||||
|
Options map[string]string `json:"Options"`
|
||||||
|
} `json:"DriverConfig"`
|
||||||
|
} `json:"VolumeOptions"`
|
||||||
|
} `json:"Mounts"`
|
||||||
|
} `json:"ContainerSpec"`
|
||||||
|
} `json:"TaskTemplate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
|
||||||
|
defer logs.CloseAndLogErr(request.Body)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &partialServiceSpec{}
|
||||||
|
if err := json.Unmarshal(body, spec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
containerSpec := spec.TaskTemplate.ContainerSpec
|
||||||
|
|
||||||
|
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
|
||||||
|
return ErrContainerCapabilitiesForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
|
||||||
|
return ErrSysCtlSettingsForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securitySettings.AllowBindMountsForRegularUsers {
|
||||||
|
for _, mount := range containerSpec.Mounts {
|
||||||
|
if mount.Type == "bind" {
|
||||||
|
return ErrBindMountsForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
|
||||||
|
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
|
||||||
|
return ErrBindMountsForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||||
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
|
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,20 +154,6 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
|
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
|
||||||
type PartialService struct {
|
|
||||||
TaskTemplate struct {
|
|
||||||
ContainerSpec struct {
|
|
||||||
Mounts []struct {
|
|
||||||
Type string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
forbiddenResponse := &http.Response{
|
|
||||||
StatusCode: http.StatusForbidden,
|
|
||||||
}
|
|
||||||
|
|
||||||
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(request.Body)
|
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport.replaceRegistryAuthenticationHeader(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
|
||||||
|
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
partialService := &PartialService{}
|
if isAdminOrEndpointAdmin {
|
||||||
if err := json.Unmarshal(body, partialService); err != nil {
|
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport.executeDockerRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
|
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
|
||||||
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
|
return &http.Response{
|
||||||
if mount.Type == "bind" {
|
StatusCode: http.StatusForbidden,
|
||||||
return forbiddenResponse, errors.New("forbidden to use bind mounts")
|
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
|
||||||
}
|
}, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return transport.replaceRegistryAuthenticationHeader(request)
|
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/api/types/swarm"
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const serviceCreationAPIVersion = "1.51"
|
||||||
|
|
||||||
|
type serviceCreationFixtures struct {
|
||||||
|
dockerSrv *httptest.Server
|
||||||
|
ds dataservices.DataStore
|
||||||
|
stdUser portainer.User
|
||||||
|
adminUser portainer.User
|
||||||
|
endpointID portainer.EndpointID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const serviceID = "some-service-id"
|
||||||
|
|
||||||
|
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
||||||
|
w.Header().Add("Api-Version", serviceCreationAPIVersion)
|
||||||
|
_, _ = w.Write([]byte{})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
data, err := json.Marshal(map[string]string{"ID": serviceID})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(dockerSrv.Close)
|
||||||
|
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
f := &serviceCreationFixtures{
|
||||||
|
dockerSrv: dockerSrv,
|
||||||
|
ds: store,
|
||||||
|
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
|
||||||
|
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
|
||||||
|
endpointID: portainer.EndpointID(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
err := tx.User().Create(&f.stdUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.User().Create(&f.adminUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
|
||||||
|
ID: f.endpointID,
|
||||||
|
Name: "test-env",
|
||||||
|
SecuritySettings: settings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceCreationFixtures) newTransport() *Transport {
|
||||||
|
return &Transport{
|
||||||
|
endpoint: &portainer.Endpoint{ID: f.endpointID},
|
||||||
|
dataStore: f.ds,
|
||||||
|
HTTPTransport: &http.Transport{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := json.Marshal(spec)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
t.Context(),
|
||||||
|
http.MethodPost,
|
||||||
|
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
|
||||||
|
bytes.NewReader(data),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
restrictiveSettings = portainer.EndpointSecuritySettings{
|
||||||
|
AllowContainerCapabilitiesForRegularUsers: false,
|
||||||
|
AllowSysctlSettingForRegularUsers: false,
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
permissiveSettings = portainer.EndpointSecuritySettings{
|
||||||
|
AllowContainerCapabilitiesForRegularUsers: true,
|
||||||
|
AllowSysctlSettingForRegularUsers: true,
|
||||||
|
AllowBindMountsForRegularUsers: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
CapabilityAdd: []string{"NET_ADMIN"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
CapabilityDrop: []string{"MKNOD"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, permissiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
CapabilityAdd: []string{"NET_ADMIN"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
var spec swarm.ServiceSpec
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, permissiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
|
||||||
|
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||||
|
AllowContainerCapabilitiesForRegularUsers: true,
|
||||||
|
AllowSysctlSettingForRegularUsers: true,
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, permissiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
CapabilityAdd: []string{"NET_ADMIN"},
|
||||||
|
CapabilityDrop: []string{"MKNOD"},
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
|
||||||
|
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
|
||||||
|
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, permissiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
CapabilityAdd: []string{"NET_ADMIN"},
|
||||||
|
Sysctls: map[string]string{"net.core.somaxconn": "128"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeBind}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{
|
||||||
|
Type: mount.TypeVolume,
|
||||||
|
VolumeOptions: &mount.VolumeOptions{
|
||||||
|
DriverConfig: &mount.Driver{
|
||||||
|
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, permissiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{
|
||||||
|
Type: mount.TypeVolume,
|
||||||
|
VolumeOptions: &mount.VolumeOptions{
|
||||||
|
DriverConfig: &mount.Driver{
|
||||||
|
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{
|
||||||
|
Type: mount.TypeVolume,
|
||||||
|
VolumeOptions: &mount.VolumeOptions{
|
||||||
|
DriverConfig: &mount.Driver{
|
||||||
|
Options: map[string]string{"type": "tmpfs"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newServiceCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, restrictiveSettings)
|
||||||
|
|
||||||
|
spec := swarm.ServiceSpec{
|
||||||
|
TaskTemplate: swarm.TaskSpec{
|
||||||
|
ContainerSpec: &swarm.ContainerSpec{
|
||||||
|
Mounts: []mount.Mount{{
|
||||||
|
Type: mount.TypeVolume,
|
||||||
|
VolumeOptions: &mount.VolumeOptions{
|
||||||
|
DriverConfig: &mount.Driver{
|
||||||
|
Options: map[string]string{"type": "bind", "device": "/etc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/logs"
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
"github.com/portainer/portainer/api/slicesx"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
@@ -109,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
|
|||||||
"volumes": (*Transport).proxyVolumeRequest,
|
"volumes": (*Transport).proxyVolumeRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type route struct {
|
||||||
|
method string
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminOnlyRoutes = []route{
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
|
||||||
|
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
|
||||||
|
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdminOnlyRoute(method string, path string) bool {
|
||||||
|
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
|
||||||
|
return method == r.method && r.pattern.MatchString(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyDockerRequest intercepts a Docker API request and apply logic based
|
// ProxyDockerRequest intercepts a Docker API request and apply logic based
|
||||||
// on the requested operation.
|
// on the requested operation.
|
||||||
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
|
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||||
@@ -137,6 +160,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
|
|||||||
return proxyFunc(transport, request, unversionedPath)
|
return proxyFunc(transport, request, unversionedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isAdminOnlyRoute(request.Method, unversionedPath) {
|
||||||
|
return transport.administratorOperation(request)
|
||||||
|
}
|
||||||
|
|
||||||
return transport.executeDockerRequest(request)
|
return transport.executeDockerRequest(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
|
|||||||
if action == "json" {
|
if action == "json" {
|
||||||
return transport.rewriteOperation(request, transport.containerInspectOperation)
|
return transport.rewriteOperation(request, transport.containerInspectOperation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if action == "update" {
|
||||||
|
return transport.decorateContainerUpdateOperation(request, containerID)
|
||||||
|
}
|
||||||
|
|
||||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||||
} else if match, _ := path.Match("/containers/*", requestPath); match {
|
} else if match, _ := path.Match("/containers/*", requestPath); match {
|
||||||
// Handle /containers/{id} requests
|
// Handle /containers/{id} requests
|
||||||
@@ -292,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
|
|||||||
if match, _ := path.Match("/services/*/*", requestPath); match {
|
if match, _ := path.Match("/services/*/*", requestPath); match {
|
||||||
// Handle /services/{id}/{action} requests
|
// Handle /services/{id}/{action} requests
|
||||||
serviceID := path.Base(path.Dir(requestPath))
|
serviceID := path.Base(path.Dir(requestPath))
|
||||||
|
action := path.Base(requestPath)
|
||||||
|
|
||||||
|
if action == "update" {
|
||||||
|
return transport.decorateServiceUpdateOperation(request, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
|
|||||||
return srv, version
|
return srv, version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransport_adminProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||||
|
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
|
||||||
|
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
|
||||||
|
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
require.NoError(t, tx.User().Create(&admin))
|
||||||
|
require.NoError(t, tx.User().Create(&std1))
|
||||||
|
require.NoError(t, tx.User().Create(&std2))
|
||||||
|
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
|
||||||
|
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
srv, version := mockDockerAPIServer(t, RoutesDefinition{
|
||||||
|
// allowed routes
|
||||||
|
{http.MethodGet, "/plugins"}: nil,
|
||||||
|
{http.MethodGet, "/plugins/xxx/json"}: nil,
|
||||||
|
{http.MethodGet, "/plugins/privileges"}: nil,
|
||||||
|
// admin routes ; see `adminOnlyRoutes`
|
||||||
|
{http.MethodDelete, "/plugins/xxx"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
|
||||||
|
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
|
||||||
|
{http.MethodPost, "/plugins/xxx/disable"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/pull"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/xxx/push"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/xxx/set"}: nil,
|
||||||
|
{http.MethodPost, "/plugins/create"}: nil,
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
transport := &Transport{
|
||||||
|
endpoint: &portainer.Endpoint{URL: srv.URL},
|
||||||
|
dataStore: ds,
|
||||||
|
HTTPTransport: &http.Transport{},
|
||||||
|
}
|
||||||
|
|
||||||
|
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
|
||||||
|
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
|
||||||
|
req = req.WithContext(security.StoreTokenData(req, &token))
|
||||||
|
require.NotNil(t, req)
|
||||||
|
|
||||||
|
return transport.ProxyDockerRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
|
||||||
|
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
|
||||||
|
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodGet, "/plugins", adminToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodGet, "/plugins", std1Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodGet, "/plugins", std2Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, http.StatusForbidden, r.StatusCode)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTransport_getRealResourceID(t *testing.T) {
|
func TestTransport_getRealResourceID(t *testing.T) {
|
||||||
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
|
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
|
||||||
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
|
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ import (
|
|||||||
"github.com/portainer/portainer/api/logs"
|
"github.com/portainer/portainer/api/logs"
|
||||||
|
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
const volumeObjectIdentifier = "ResourceID"
|
const volumeObjectIdentifier = "ResourceID"
|
||||||
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
|
|||||||
return utils.GetJSONObject(responseObject, "Labels")
|
return utils.GetJSONObject(responseObject, "Labels")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CheckVolumeBodyRestrictions(request *http.Request) error {
|
||||||
|
defer logs.CloseAndLogErr(request.Body)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumeCreateBody struct {
|
||||||
|
DriverOpts map[string]string `json:"DriverOpts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if volumeCreateBody.DriverOpts["type"] == "bind" {
|
||||||
|
return ErrBindMountsForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||||
tokenData, err := security.RetrieveTokenData(request)
|
tokenData, err := security.RetrieveTokenData(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAdminOrEndpointAdmin {
|
||||||
|
securitySettings, err := transport.fetchEndpointSecuritySettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !securitySettings.AllowBindMountsForRegularUsers {
|
||||||
|
if err := CheckVolumeBodyRestrictions(request); err != nil {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
volumeID := request.Header.Get("X-Portainer-VolumeName")
|
volumeID := request.Header.Get("X-Portainer-VolumeName")
|
||||||
|
|
||||||
if volumeID != "" {
|
if volumeID != "" {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const volumeCreationAPIVersion = "1.51"
|
||||||
|
|
||||||
|
type volumeCreationFixtures struct {
|
||||||
|
dockerSrv *httptest.Server
|
||||||
|
ds dataservices.DataStore
|
||||||
|
stdUser portainer.User
|
||||||
|
adminUser portainer.User
|
||||||
|
endpointID portainer.EndpointID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
||||||
|
w.Header().Add("Api-Version", volumeCreationAPIVersion)
|
||||||
|
_, _ = w.Write([]byte{})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(dockerSrv.Close)
|
||||||
|
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
f := &volumeCreationFixtures{
|
||||||
|
dockerSrv: dockerSrv,
|
||||||
|
ds: store,
|
||||||
|
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
|
||||||
|
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
|
||||||
|
endpointID: portainer.EndpointID(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
err := tx.User().Create(&f.stdUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.User().Create(&f.adminUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
|
||||||
|
ID: f.endpointID,
|
||||||
|
Name: "test-env",
|
||||||
|
SecuritySettings: settings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *volumeCreationFixtures) newTransport() *Transport {
|
||||||
|
return &Transport{
|
||||||
|
endpoint: &portainer.Endpoint{ID: f.endpointID},
|
||||||
|
dataStore: f.ds,
|
||||||
|
HTTPTransport: &http.Transport{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
t.Context(),
|
||||||
|
http.MethodPost,
|
||||||
|
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
|
||||||
|
bytes.NewReader(data),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newVolumeCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := volume.CreateOptions{
|
||||||
|
Name: "evil-volume",
|
||||||
|
Driver: "local",
|
||||||
|
DriverOpts: map[string]string{
|
||||||
|
"type": "bind",
|
||||||
|
"device": "/etc",
|
||||||
|
"o": "bind",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||||
|
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newVolumeCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := volume.CreateOptions{
|
||||||
|
Name: "admin-volume",
|
||||||
|
Driver: "local",
|
||||||
|
DriverOpts: map[string]string{
|
||||||
|
"type": "bind",
|
||||||
|
"device": "/etc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newVolumeCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := volume.CreateOptions{
|
||||||
|
Name: "allowed-volume",
|
||||||
|
Driver: "local",
|
||||||
|
DriverOpts: map[string]string{
|
||||||
|
"type": "bind",
|
||||||
|
"device": "/data",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newVolumeCreationFixtures(t)
|
||||||
|
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := volume.CreateOptions{
|
||||||
|
Name: "normal-volume",
|
||||||
|
Driver: "local",
|
||||||
|
DriverOpts: map[string]string{
|
||||||
|
"type": "tmpfs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
|
||||||
|
require.NotErrorIs(t, err, ErrBindMountsForbidden)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
err = resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
|
|||||||
return tokenData, nil
|
return tokenData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
|
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
|
||||||
func extractBearerToken(r *http.Request) (string, bool) {
|
func extractBearerToken(r *http.Request) (string, bool) {
|
||||||
// Token might be set via the "token" query parameter.
|
|
||||||
// For example, in websocket requests
|
|
||||||
// For these cases, hide the token from the query
|
|
||||||
query := r.URL.Query()
|
|
||||||
token := query.Get("token")
|
|
||||||
if token != "" {
|
|
||||||
query.Del("token")
|
|
||||||
r.URL.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
return token, true
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, ok := r.Header[jwtTokenHeader]
|
tokens, ok := r.Header[jwtTokenHeader]
|
||||||
if !ok || len(tokens) == 0 {
|
if !ok || len(tokens) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
token = tokens[0]
|
token := tokens[0]
|
||||||
token = strings.TrimPrefix(token, "Bearer ")
|
token = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
|
||||||
return token, true
|
return token, true
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package registryutils
|
package registryutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
@@ -14,9 +16,12 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
|
|||||||
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
|
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
func fetchRegToken(registry *portainer.Registry) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
|
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
|
||||||
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
|
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
|
|||||||
registry.AccessToken = *accessToken
|
registry.AccessToken = *accessToken
|
||||||
registry.AccessTokenExpiry = expiryAt.Unix()
|
registry.AccessTokenExpiry = expiryAt.Unix()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||||
|
if err := fetchRegToken(registry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Registry().Update(registry.ID, registry)
|
return tx.Registry().Update(registry.ID, registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
|
// ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
|
||||||
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
|
// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
|
||||||
ParseAuthorizationToken(registry.AccessToken)
|
func ValidateRegistriesECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) error {
|
||||||
|
for i := range registries {
|
||||||
|
reg := ®istries[i]
|
||||||
|
if reg.Type != portainer.EcrRegistry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isRegTokenValid(reg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := doGetRegToken(tx, reg); err != nil {
|
||||||
|
return fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", reg.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||||
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
|
|||||||
password = registry.Password
|
password = registry.Password
|
||||||
|
|
||||||
if registry.Type == portainer.EcrRegistry {
|
if registry.Type == portainer.EcrRegistry {
|
||||||
username, password, err = parseRegToken(registry)
|
// Fallback token refresh in case the upstream caller did not pre-validate the token.
|
||||||
|
if !isRegTokenValid(registry) {
|
||||||
|
if err := fetchRegToken(registry); err != nil {
|
||||||
|
return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
|
||||||
|
ParseAuthorizationToken(registry.AccessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package registryutils_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/internal/registryutils"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry {
|
||||||
|
return portainer.Registry{
|
||||||
|
ID: id,
|
||||||
|
Type: portainer.EcrRegistry,
|
||||||
|
Name: "test-ecr",
|
||||||
|
Username: "AKIAIOSFODNN7EXAMPLE",
|
||||||
|
Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
|
Ecr: portainer.EcrData{Region: "us-east-1"},
|
||||||
|
AccessToken: accessToken,
|
||||||
|
AccessTokenExpiry: expiry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRegistriesECRTokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("skips non-ECR registries without error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
registries := []portainer.Registry{
|
||||||
|
{ID: 1, Type: portainer.DockerHubRegistry, Name: "dockerhub"},
|
||||||
|
{ID: 2, Type: portainer.CustomRegistry, Name: "custom"},
|
||||||
|
}
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return registryutils.ValidateRegistriesECRTokens(tx, registries)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skips ECR registries with valid tokens", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns nil for empty registry list", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error for ECR registry with missing token", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
reg := newECRRegistry(1, "", 0)
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return tx.Registry().Create(®)
|
||||||
|
}))
|
||||||
|
|
||||||
|
var validateErr error
|
||||||
|
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.Error(t, validateErr)
|
||||||
|
require.Contains(t, validateErr.Error(), "test-ecr")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stops on first invalid ECR registry and includes its name in error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ds := datastore.MustNewTestStore(t, true, false)
|
||||||
|
|
||||||
|
validECR := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
|
||||||
|
invalidECR := newECRRegistry(2, "", 0)
|
||||||
|
invalidECR.Name = "invalid-ecr"
|
||||||
|
nonECR := portainer.Registry{ID: 3, Type: portainer.DockerHubRegistry}
|
||||||
|
|
||||||
|
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return tx.Registry().Create(&invalidECR)
|
||||||
|
}))
|
||||||
|
|
||||||
|
var validateErr error
|
||||||
|
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{validECR, invalidECR, nonECR})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.Error(t, validateErr)
|
||||||
|
require.Contains(t, validateErr.Error(), "invalid-ecr")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRegEffectiveCredential(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
reg := &portainer.Registry{
|
||||||
|
Type: portainer.DockerHubRegistry,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
}
|
||||||
|
username, password, err := registryutils.GetRegEffectiveCredential(reg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "user", username)
|
||||||
|
require.Equal(t, "pass", password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parses ECR access token when token is valid", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix())
|
||||||
|
username, password, err := registryutils.GetRegEffectiveCredential(®)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "AWS", username)
|
||||||
|
require.Equal(t, "ecr-password", password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
reg := newECRRegistry(1, "", 0)
|
||||||
|
_, _, err := registryutils.GetRegEffectiveCredential(®)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "test-ecr")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -147,6 +147,27 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubSSLSettingsService struct {
|
||||||
|
settings *portainer.SSLSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubSSLSettingsService) BucketName() string { return "ssl" }
|
||||||
|
|
||||||
|
func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) {
|
||||||
|
return s.settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||||
|
s.settings = settings
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption {
|
||||||
|
return func(d *testDatastore) {
|
||||||
|
d.sslSettings = &stubSSLSettingsService{settings: settings}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubUserService struct {
|
type stubUserService struct {
|
||||||
dataservices.UserService
|
dataservices.UserService
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, err
|
|||||||
|
|
||||||
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
|
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
|
||||||
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
|
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
|
||||||
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
|
jobs, err := kcl.getCronJobExecutions(cronJob.Name, cronJob.Namespace, jobsList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.K8sCronJob{}
|
return models.K8sCronJob{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
batchv1 "k8s.io/api/batch/v1"
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
kfake "k8s.io/client-go/kubernetes/fake"
|
kfake "k8s.io/client-go/kubernetes/fake"
|
||||||
)
|
)
|
||||||
@@ -64,3 +67,62 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
|
|||||||
t.Logf("Deleted Cron Jobs")
|
t.Logf("Deleted Cron Jobs")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetCronJobExecutionsNamespaceFilter verifies that getCronJobExecutions only returns
|
||||||
|
// executions belonging to the CronJob's own namespace, even when same-named CronJobs
|
||||||
|
// exist across multiple namespaces.
|
||||||
|
func TestGetCronJobExecutionsNamespaceFilter(t *testing.T) {
|
||||||
|
backoffLimit := int32(3)
|
||||||
|
completions := int32(1)
|
||||||
|
|
||||||
|
makeJob := func(name, namespace, cronJobName string) batchv1.Job {
|
||||||
|
return batchv1.Job{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Namespace: namespace,
|
||||||
|
OwnerReferences: []metav1.OwnerReference{
|
||||||
|
{Kind: "CronJob", Name: cronJobName},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: batchv1.JobSpec{
|
||||||
|
BackoffLimit: &backoffLimit,
|
||||||
|
Completions: &completions,
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{{Name: "worker", Image: "busybox"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the cross-namespace job list returned when fetchCronJobs is called with namespace=""
|
||||||
|
allJobs := &batchv1.JobList{
|
||||||
|
Items: []batchv1.Job{
|
||||||
|
makeJob("backup-prod-28001440", "ns-prod", "backup"),
|
||||||
|
makeJob("backup-test-28001441", "ns-test", "backup"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
kcl := &KubeClient{
|
||||||
|
cli: kfake.NewSimpleClientset(),
|
||||||
|
instanceID: "test",
|
||||||
|
isKubeAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("returns only executions from the matching namespace", func(t *testing.T) {
|
||||||
|
result, err := kcl.getCronJobExecutions("backup", "ns-prod", allJobs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "ns-prod", result[0].Namespace)
|
||||||
|
assert.Equal(t, "backup-prod-28001440", result[0].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns only executions from the other matching namespace", func(t *testing.T) {
|
||||||
|
result, err := kcl.getCronJobExecutions("backup", "ns-test", allJobs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "ns-test", result[0].Namespace)
|
||||||
|
assert.Equal(t, "backup-test-28001441", result[0].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+26
-10
@@ -3,8 +3,10 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
|||||||
TTY: true,
|
TTY: true,
|
||||||
}, scheme.ParameterCodec)
|
}, scheme.ParameterCodec)
|
||||||
|
|
||||||
|
streamOpts := remotecommand.StreamOptions{
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: stdout,
|
||||||
|
Tty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try WebSocket executor first, fall back to SPDY if it fails
|
||||||
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
|
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
|
||||||
config,
|
config,
|
||||||
"GET", // WebSocket uses GET for the upgrade request
|
"GET", // WebSocket uses GET for the upgrade request
|
||||||
req.URL().String(),
|
req.URL().String(),
|
||||||
channelProtocolList...,
|
channelProtocolList...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
errChan <- err
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("context", "StartExecProcess").
|
||||||
|
Msg("WebSocket exec failed, falling back to SPDY")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
|
// Fall back to SPDY executor
|
||||||
Stdin: stdin,
|
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||||
Stdout: stdout,
|
if err != nil {
|
||||||
Tty: true,
|
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitError utilexec.ExitError
|
var exitError utilexec.ExitError
|
||||||
if !errors.As(err, &exitError) {
|
if !errors.As(err, &exitError) {
|
||||||
errChan <- errors.New("unable to start exec process")
|
errChan <- fmt.Errorf("unable to start exec process: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,11 +168,15 @@ func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
|
|||||||
|
|
||||||
// getCronJobExecutions returns the jobs for a given cronjob
|
// getCronJobExecutions returns the jobs for a given cronjob
|
||||||
// it returns the jobs for the cronjob
|
// it returns the jobs for the cronjob
|
||||||
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, cronJobNamespace string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
||||||
maxItems := 5
|
maxItems := 5
|
||||||
|
|
||||||
results := make([]models.K8sJob, 0)
|
results := make([]models.K8sJob, 0)
|
||||||
for _, job := range jobs.Items {
|
for _, job := range jobs.Items {
|
||||||
|
if job.Namespace != cronJobNamespace {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, owner := range job.OwnerReferences {
|
for _, owner := range job.OwnerReferences {
|
||||||
if owner.Kind == "CronJob" && owner.Name == cronJobName {
|
if owner.Kind == "CronJob" && owner.Name == cronJobName {
|
||||||
results = append(results, kcl.parseJob(job))
|
results = append(results, kcl.parseJob(job))
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
|
|||||||
name: portainer-ctx
|
name: portainer-ctx
|
||||||
current-context: portainer-ctx
|
current-context: portainer-ctx
|
||||||
kind: Config
|
kind: Config
|
||||||
preferences: {}
|
|
||||||
users:
|
users:
|
||||||
- name: test-user
|
- name: test-user
|
||||||
user:
|
user:
|
||||||
|
|||||||
+5
-5
@@ -73,7 +73,7 @@ func (Service) AuthenticateUser(username, password string, settings *portainer.L
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
if !settings.AnonymousMode {
|
if !settings.AnonymousMode {
|
||||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
@@ -108,7 +108,7 @@ func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
if !settings.AnonymousMode {
|
if !settings.AnonymousMode {
|
||||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
@@ -133,7 +133,7 @@ func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
if !settings.AnonymousMode {
|
if !settings.AnonymousMode {
|
||||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
@@ -182,7 +182,7 @@ func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPU
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
if !settings.AnonymousMode {
|
if !settings.AnonymousMode {
|
||||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
@@ -309,7 +309,7 @@ func (Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
if !settings.AnonymousMode {
|
if !settings.AnonymousMode {
|
||||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestCreateConnectionForURL(t *testing.T) {
|
|||||||
conn, err := createConnectionForURL(settings.URL, settings)
|
conn, err := createConnectionForURL(settings.URL, settings)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, conn)
|
require.NotNil(t, conn)
|
||||||
conn.Close()
|
require.NoError(t, conn.Close())
|
||||||
|
|
||||||
// TLS
|
// TLS
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ func TestCreateConnectionForURL(t *testing.T) {
|
|||||||
conn, err = createConnectionForURL(settings.URL, settings)
|
conn, err = createConnectionForURL(settings.URL, settings)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, conn)
|
require.NotNil(t, conn)
|
||||||
conn.Close()
|
require.NoError(t, conn.Close())
|
||||||
|
|
||||||
// Invalid TLS
|
// Invalid TLS
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -141,6 +141,7 @@ type (
|
|||||||
LogLevel *string
|
LogLevel *string
|
||||||
LogMode *string
|
LogMode *string
|
||||||
KubectlShellImage *string
|
KubectlShellImage *string
|
||||||
|
KubectlShellImageSet bool
|
||||||
PullLimitCheckDisabled *bool
|
PullLimitCheckDisabled *bool
|
||||||
TrustedOrigins *string
|
TrustedOrigins *string
|
||||||
}
|
}
|
||||||
@@ -576,7 +577,7 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
PolicyChartBundle struct {
|
PolicyChartBundle struct {
|
||||||
PolicyChartSummary
|
PolicyChartSummary `mapstructure:",squash"`
|
||||||
EncodedTgz string `json:"EncodedTgz"`
|
EncodedTgz string `json:"EncodedTgz"`
|
||||||
Namespace string `json:"Namespace"`
|
Namespace string `json:"Namespace"`
|
||||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||||
@@ -1874,7 +1875,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.39.0"
|
APIVersion = "2.39.2"
|
||||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||||
APIVersionSupport = "LTS"
|
APIVersionSupport = "LTS"
|
||||||
// Edition is what this edition of Portainer is called
|
// Edition is what this edition of Portainer is called
|
||||||
|
|||||||
@@ -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,
|
ComposeOptions: options,
|
||||||
ForceRecreate: forceRecreate,
|
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 {
|
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/registryutils"
|
||||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -44,6 +45,10 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
|
|||||||
|
|
||||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||||
|
|
||||||
|
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
config := &ComposeStackDeploymentConfig{
|
config := &ComposeStackDeploymentConfig{
|
||||||
stack: stack,
|
stack: stack,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/registryutils"
|
||||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +44,10 @@ func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityCon
|
|||||||
|
|
||||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||||
|
|
||||||
|
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
config := &SwarmStackDeploymentConfig{
|
config := &SwarmStackDeploymentConfig{
|
||||||
stack: stack,
|
stack: stack,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package stackutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/dotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildEnvMap builds the environment variable map for stack validation/loading.
|
||||||
|
// Priority (lowest to highest): OS env → .env file → stack.Env
|
||||||
|
func BuildEnvMap(stack *portainer.Stack) map[string]string {
|
||||||
|
env := make(map[string]string, len(os.Environ()))
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
k, v, _ := strings.Cut(e, "=")
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
dotEnvPath := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
|
||||||
|
if dotVars, err := dotenv.Read(dotEnvPath); err == nil {
|
||||||
|
maps.Copy(env, dotVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pair := range stack.Env {
|
||||||
|
env[pair.Name] = pair.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
package stackutils
|
package stackutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
"context"
|
||||||
|
"path"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/docker/cli/cli/compose/types"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
|
|
||||||
|
composeloader "github.com/compose-spec/compose-go/v2/loader"
|
||||||
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
|
type StackFileValidationConfig struct {
|
||||||
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
|
Content []byte
|
||||||
|
SecuritySettings *portainer.EndpointSecuritySettings
|
||||||
|
Env map[string]string
|
||||||
|
WorkingDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidStackFile(config StackFileValidationConfig) error {
|
||||||
|
composeConfigDetails := composetypes.ConfigDetails{
|
||||||
|
ConfigFiles: []composetypes.ConfigFile{{Content: config.Content}},
|
||||||
|
Environment: config.Env,
|
||||||
|
WorkingDir: config.WorkingDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
composeConfig, err := composeloader.LoadWithContext(context.Background(), composeConfigDetails, composeloader.WithSkipValidation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
composeConfigFile := types.ConfigFile{
|
for _, service := range composeConfig.Services {
|
||||||
Config: composeConfigYAML,
|
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
|
||||||
}
|
|
||||||
|
|
||||||
composeConfigDetails := types.ConfigDetails{
|
|
||||||
ConfigFiles: []types.ConfigFile{composeConfigFile},
|
|
||||||
Environment: map[string]string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) {
|
|
||||||
options.SkipValidation = true
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for key := range composeConfig.Services {
|
|
||||||
service := composeConfig.Services[key]
|
|
||||||
if !securitySettings.AllowBindMountsForRegularUsers {
|
|
||||||
for _, volume := range service.Volumes {
|
for _, volume := range service.Volumes {
|
||||||
if volume.Type == "bind" {
|
if volume.Type == "bind" {
|
||||||
return errors.New("bind-mount disabled for non administrator users")
|
return errors.New("bind-mount disabled for non administrator users")
|
||||||
@@ -40,23 +40,23 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
|
if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
|
||||||
return errors.New("privileged mode disabled for non administrator users")
|
return errors.New("privileged mode disabled for non administrator users")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
||||||
return errors.New("pid host disabled for non administrator users")
|
return errors.New("pid host disabled for non administrator users")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
|
if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
|
||||||
return errors.New("device mapping disabled for non administrator users")
|
return errors.New("device mapping disabled for non administrator users")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
|
if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
|
||||||
return errors.New("sysctl setting disabled for non administrator users")
|
return errors.New("sysctl setting disabled for non administrator users")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||||
return errors.New("container capabilities disabled for non administrator users")
|
return errors.New("container capabilities disabled for non administrator users")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,13 +65,21 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
|
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
|
||||||
|
env := BuildEnvMap(stack)
|
||||||
|
workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint))
|
||||||
|
|
||||||
for _, file := range GetStackFilePaths(stack, false) {
|
for _, file := range GetStackFilePaths(stack, false) {
|
||||||
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
|
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to get stack file content")
|
return errors.Wrap(err, "failed to get stack file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := IsValidStackFile(stackContent, securitySettings); err != nil {
|
if err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: stackContent,
|
||||||
|
SecuritySettings: securitySettings,
|
||||||
|
Env: env,
|
||||||
|
WorkingDir: workingDir,
|
||||||
|
}); err != nil {
|
||||||
return errors.Wrap(err, "stack config file is invalid")
|
return errors.Wrap(err, "stack config file is invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package stackutils
|
package stackutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,32 +31,236 @@ networks:
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
err := IsValidStackFile(yamlContent, securitySettings)
|
err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: yamlContent,
|
||||||
|
SecuritySettings: securitySettings,
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsValidStackFile_PortEnv(t *testing.T) {
|
// TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects
|
||||||
yamlContent := []byte(`
|
// validation when the env var is not provided. Docker accepts an empty host port (left side)
|
||||||
|
// but requires a valid container port (right side).
|
||||||
|
func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) {
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
|
||||||
|
t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) {
|
||||||
|
err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "${API_PORT}:9090"
|
||||||
|
`),
|
||||||
|
SecuritySettings: securitySettings,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("var on right side fails", func(t *testing.T) {
|
||||||
|
err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "9090:${API_PORT}"
|
||||||
|
`),
|
||||||
|
SecuritySettings: securitySettings,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("var on both sides fails", func(t *testing.T) {
|
||||||
|
err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "${API_PORT}:${API_PORT}"
|
||||||
|
`),
|
||||||
|
SecuritySettings: securitySettings,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) {
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
err := IsValidStackFile(StackFileValidationConfig{
|
||||||
|
Content: []byte(`
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
webservice:
|
api:
|
||||||
image: nginx
|
image: nginx
|
||||||
container_name: hello-world
|
|
||||||
networks:
|
|
||||||
- "mynet1"
|
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:80"
|
- "${API_PORT}:${API_PORT}"
|
||||||
|
`),
|
||||||
networks:
|
SecuritySettings: securitySettings,
|
||||||
mynet1:
|
Env: map[string]string{"API_PORT": "3000"},
|
||||||
driver: bridge
|
})
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.16.0.0/24
|
|
||||||
`)
|
|
||||||
|
|
||||||
securitySettings := &portainer.EndpointSecuritySettings{}
|
|
||||||
err := IsValidStackFile(yamlContent, securitySettings)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockFileService struct {
|
||||||
|
portainer.FileService
|
||||||
|
fileContent []byte
|
||||||
|
projectVersionPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) {
|
||||||
|
return m.fileContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string {
|
||||||
|
return m.projectVersionPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStackFiles_EnvVars(t *testing.T) {
|
||||||
|
fileContent := []byte(`
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "${API_PORT}:${API_PORT}"
|
||||||
|
`)
|
||||||
|
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
|
||||||
|
ProjectPath: "/tmp/stack/1",
|
||||||
|
EntryPoint: "docker-compose.yml",
|
||||||
|
Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService := mockFileService{
|
||||||
|
fileContent: fileContent,
|
||||||
|
projectVersionPath: "/tmp/stack/1",
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStackFiles_OSEnvVar(t *testing.T) {
|
||||||
|
t.Setenv("HOST_PORT", "3000")
|
||||||
|
|
||||||
|
fileContent := []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "80:${HOST_PORT}"
|
||||||
|
`)
|
||||||
|
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
ProjectPath: "/tmp/stack/1",
|
||||||
|
EntryPoint: "docker-compose.yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService := mockFileService{
|
||||||
|
fileContent: fileContent,
|
||||||
|
projectVersionPath: "/tmp/stack/1",
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStackFiles_DotEnvFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileContent := []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- "80:${HOST_PORT}"
|
||||||
|
`)
|
||||||
|
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
ProjectPath: tmpDir,
|
||||||
|
EntryPoint: "docker-compose.yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService := mockFileService{
|
||||||
|
fileContent: fileContent,
|
||||||
|
projectVersionPath: tmpDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
err = ValidateStackFiles(stack, securitySettings, fileService)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStackFiles_EnvFileAttribute(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileContent := []byte(`
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
env_file:
|
||||||
|
- ./web.env
|
||||||
|
`)
|
||||||
|
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
ProjectPath: tmpDir,
|
||||||
|
EntryPoint: "docker-compose.yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService := mockFileService{
|
||||||
|
fileContent: fileContent,
|
||||||
|
projectVersionPath: tmpDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{}
|
||||||
|
err = ValidateStackFiles(stack, securitySettings, fileService)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) {
|
||||||
|
fileContent := []byte(`
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- /host/path:/container/path
|
||||||
|
`)
|
||||||
|
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
ProjectPath: "/tmp/stack/1",
|
||||||
|
EntryPoint: "docker-compose.yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
fileService := mockFileService{
|
||||||
|
fileContent: fileContent,
|
||||||
|
projectVersionPath: "/tmp/stack/1",
|
||||||
|
}
|
||||||
|
|
||||||
|
securitySettings := &portainer.EndpointSecuritySettings{
|
||||||
|
AllowBindMountsForRegularUsers: false,
|
||||||
|
}
|
||||||
|
err := ValidateStackFiles(stack, securitySettings, fileService)
|
||||||
|
require.ErrorContains(t, err, "bind-mount disabled for non administrator users")
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,24 @@ func StackResourceControlGetter[
|
|||||||
func StackResourceControlID(endpointID portainer.EndpointID, name string) string {
|
func StackResourceControlID(endpointID portainer.EndpointID, name string) string {
|
||||||
return stackutils.ResourceControlID(endpointID, name)
|
return stackutils.ResourceControlID(endpointID, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExternalStack struct {
|
||||||
|
Labels map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// External stacks are indirectly detected either via containers or services labels
|
||||||
|
// Any UAC applied to them can only be fetched from containers'/services' labels
|
||||||
|
func ExternalStackResourceControlGetter[
|
||||||
|
TX txLike[RCS, TS, US],
|
||||||
|
RCS rcServiceLike,
|
||||||
|
TS teamServiceLike,
|
||||||
|
US userServiceLike,
|
||||||
|
](
|
||||||
|
tx TX,
|
||||||
|
endpointID portainer.EndpointID) func(item ExternalStack) (*portainer.ResourceControl, error) {
|
||||||
|
return genericResourcControlGetter(tx, endpointID, ResourceContext[ExternalStack]{
|
||||||
|
RCType: portainer.StackResourceControl,
|
||||||
|
IDGetter: func(s ExternalStack) string { return "0" },
|
||||||
|
LabelsGetter: func(es ExternalStack) map[string]string { return es.Labels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export interface StandaloneFileContentPayload {
|
|||||||
|
|
||||||
stackFileContent: string;
|
stackFileContent: string;
|
||||||
/** List of environment variables */
|
/** List of environment variables */
|
||||||
env?: Array<Pair>;
|
env?: Array<Pair> | null;
|
||||||
|
|
||||||
/** Whether the stack is from an app template */
|
/** Whether the stack is from an app template */
|
||||||
fromAppTemplate?: boolean;
|
fromAppTemplate?: boolean;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SwarmFileContentPayload {
|
|||||||
|
|
||||||
stackFileContent: string;
|
stackFileContent: string;
|
||||||
/** List of environment variables */
|
/** List of environment variables */
|
||||||
env?: Array<Pair>;
|
env?: Array<Pair> | null;
|
||||||
|
|
||||||
/** Whether the stack is from an app template */
|
/** Whether the stack is from an app template */
|
||||||
fromAppTemplate?: boolean;
|
fromAppTemplate?: boolean;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface Stack {
|
|||||||
EndpointId: number;
|
EndpointId: number;
|
||||||
SwarmId: string;
|
SwarmId: string;
|
||||||
EntryPoint: string;
|
EntryPoint: string;
|
||||||
Env: EnvVar[];
|
Env: EnvVar[] | null;
|
||||||
ResourceControl?: ResourceControlResponse;
|
ResourceControl?: ResourceControlResponse;
|
||||||
Status: StackStatus;
|
Status: StackStatus;
|
||||||
ProjectPath: string;
|
ProjectPath: string;
|
||||||
@@ -84,7 +84,7 @@ export type StackFile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface GitStackPayload {
|
export interface GitStackPayload {
|
||||||
env: Array<EnvVar>;
|
env: Array<EnvVar> | null;
|
||||||
prune?: boolean;
|
prune?: boolean;
|
||||||
RepositoryReferenceName?: string;
|
RepositoryReferenceName?: string;
|
||||||
RepositoryAuthentication?: boolean;
|
RepositoryAuthentication?: boolean;
|
||||||
|
|||||||
@@ -66,7 +66,6 @@
|
|||||||
.pagination > .active > span:focus,
|
.pagination > .active > span:focus,
|
||||||
.pagination > .active > button:focus {
|
.pagination > .active > button:focus {
|
||||||
@apply text-blue-7;
|
@apply text-blue-7;
|
||||||
z-index: 3;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
/* background-color: var(--text-pagination-span-color); */
|
/* background-color: var(--text-pagination-span-color); */
|
||||||
background-color: var(--bg-pagination-color);
|
background-color: var(--bg-pagination-color);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
|
|||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
'fixed z-50 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
|
|||||||
@@ -15,11 +15,10 @@ export function StickyFooter({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.actionBar,
|
styles.actionBar,
|
||||||
// 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-40 h-16',
|
||||||
'fixed bottom-0 right-0 z-10 h-16',
|
|
||||||
'flex items-center px-6',
|
'flex items-center px-6',
|
||||||
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
|
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
|
||||||
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
|
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
import { confirmDelete } from '@@/modals/confirm';
|
import { confirmDelete } from '@@/modals/confirm';
|
||||||
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { LoadingButton } from './LoadingButton';
|
import { LoadingButton } from './LoadingButton';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
type ConfirmOrClick =
|
type ConfirmOrClick =
|
||||||
| {
|
| {
|
||||||
@@ -26,7 +27,10 @@ export function DeleteButton({
|
|||||||
size,
|
size,
|
||||||
children,
|
children,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
text = 'Remove',
|
||||||
loadingText = 'Removing...',
|
loadingText = 'Removing...',
|
||||||
|
icon = false,
|
||||||
|
type,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
@@ -35,7 +39,10 @@ export function DeleteButton({
|
|||||||
size?: ComponentProps<typeof Button>['size'];
|
size?: ComponentProps<typeof Button>['size'];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
text?: string;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
|
icon?: boolean;
|
||||||
|
type?: ComponentProps<typeof Button>['type'];
|
||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
if (isLoading === undefined) {
|
if (isLoading === undefined) {
|
||||||
@@ -46,10 +53,11 @@ export function DeleteButton({
|
|||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
onClick={() => handleClick()}
|
onClick={() => handleClick()}
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
className="!m-0"
|
className={clsx('!m-0', icon ? 'btn-icon' : '')}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
|
type={type}
|
||||||
>
|
>
|
||||||
{children || 'Remove'}
|
{children || text}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,7 @@ export function DeleteButton({
|
|||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
loadingText={loadingText}
|
loadingText={loadingText}
|
||||||
|
type={type}
|
||||||
>
|
>
|
||||||
{children || 'Remove'}
|
{children || 'Remove'}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { difference } from 'lodash';
|
||||||
import {
|
import {
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
@@ -14,6 +16,7 @@ import { defaultGetRowId } from './defaultGetRowId';
|
|||||||
import { Table } from './Table';
|
import { Table } from './Table';
|
||||||
import { NestedTable } from './NestedTable';
|
import { NestedTable } from './NestedTable';
|
||||||
import { DatatableContent } from './DatatableContent';
|
import { DatatableContent } from './DatatableContent';
|
||||||
|
import { DatatableFooter } from './DatatableFooter';
|
||||||
import { BasicTableSettings, DefaultType } from './types';
|
import { BasicTableSettings, DefaultType } from './types';
|
||||||
|
|
||||||
interface Props<D extends DefaultType> extends AutomationTestingProps {
|
interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||||
@@ -69,6 +72,18 @@ export function NestedDatatable<D extends DefaultType>({
|
|||||||
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
|
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tableState = tableInstance.getState();
|
||||||
|
const selectedRowModel = tableInstance.getSelectedRowModel();
|
||||||
|
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
||||||
|
const filteredItems = tableInstance
|
||||||
|
.getFilteredRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
|
||||||
|
const hiddenSelectedItems = useMemo(
|
||||||
|
() => difference(selectedItems, filteredItems),
|
||||||
|
[selectedItems, filteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NestedTable>
|
<NestedTable>
|
||||||
<Table.Container noWidget>
|
<Table.Container noWidget>
|
||||||
@@ -80,6 +95,17 @@ export function NestedDatatable<D extends DefaultType>({
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
/>
|
/>
|
||||||
|
{enablePagination && (
|
||||||
|
<DatatableFooter
|
||||||
|
onPageChange={tableInstance.setPageIndex}
|
||||||
|
onPageSizeChange={tableInstance.setPageSize}
|
||||||
|
page={tableState.pagination.pageIndex}
|
||||||
|
pageSize={tableState.pagination.pageSize}
|
||||||
|
pageCount={tableInstance.getPageCount()}
|
||||||
|
totalSelected={selectedItems.length}
|
||||||
|
totalHiddenSelected={hiddenSelectedItems.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
</NestedTable>
|
</NestedTable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,31 +7,43 @@ interface Props<D extends DefaultType = DefaultType> {
|
|||||||
cells: Cell<D, unknown>[];
|
cells: Cell<D, unknown>[];
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
'aria-selected'?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow<D extends DefaultType = DefaultType>({
|
export function TableRow<D extends DefaultType = DefaultType>({
|
||||||
cells,
|
cells,
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
|
'aria-selected': ariaSelected,
|
||||||
}: Props<D>) {
|
}: Props<D>) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className={clsx(className, { 'cursor-pointer': !!onClick })}
|
className={clsx(className, { 'cursor-pointer': !!onClick })}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-selected={ariaSelected}
|
||||||
>
|
>
|
||||||
{cells.map((cell) => (
|
{cells.map((cell) => {
|
||||||
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}>
|
const { className, width } = parseMeta(cell.column.columnDef.meta);
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
return (
|
||||||
</td>
|
<td key={cell.id} className={className} style={{ width }}>
|
||||||
))}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClassName<D extends DefaultType = DefaultType>(
|
function parseMeta<D extends DefaultType = DefaultType>(
|
||||||
meta: ColumnMeta<D, unknown> | undefined
|
meta: ColumnMeta<D, unknown> | undefined
|
||||||
) {
|
) {
|
||||||
return !!meta && 'className' in meta && typeof meta.className === 'string'
|
const className =
|
||||||
? meta.className
|
!!meta && 'className' in meta && typeof meta.className === 'string'
|
||||||
: '';
|
? meta.className
|
||||||
|
: '';
|
||||||
|
const width =
|
||||||
|
!!meta && 'width' in meta && typeof meta.width === 'string'
|
||||||
|
? meta.width
|
||||||
|
: undefined;
|
||||||
|
return { className, width };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
|||||||
),
|
),
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
meta: {
|
meta: {
|
||||||
width: 50,
|
width: '50px',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function FormSection({
|
|||||||
</FormSectionTitle>
|
</FormSectionTitle>
|
||||||
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
|
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
|
||||||
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
|
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
|
||||||
{isExpanded && <div className="clear-both">{children}</div>}
|
<div className="clear-both">{isExpanded && children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export function Modal({
|
|||||||
<Context.Provider value>
|
<Context.Provider value>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
isOpen
|
isOpen
|
||||||
className={clsx(
|
className={clsx(styles.overlay, 'flex items-center justify-center')}
|
||||||
styles.overlay,
|
|
||||||
'flex items-center justify-center z-50'
|
|
||||||
)}
|
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
|
// When a Sheet is open and then a Modal opens, Radix DismissableLayer sets body.style.pointerEvents="none" for this modal overlay, so make it auto here.
|
||||||
|
// z-index ensures the modal renders above the base views and any Sheet (z-50).
|
||||||
|
style={{ zIndex: 60, pointerEvents: 'auto' }}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
.background-error {
|
.background-error {
|
||||||
padding-top: 55px;
|
padding-top: 55px;
|
||||||
background-image: url(~assets/images/icon-error.svg);
|
background-image: url(~@/assets/images/icon-error.svg);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: top left;
|
background-position: top left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-warning {
|
.background-warning {
|
||||||
padding-top: 55px;
|
padding-top: 55px;
|
||||||
background-image: url(~assets/images/icon-warning.svg);
|
background-image: url(~@/assets/images/icon-warning.svg);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: top left;
|
background-position: top left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function HealthStatus({ health }: Props) {
|
|||||||
<div className="vertical-center">{health.FailingStreak}</div>
|
<div className="vertical-center">{health.FailingStreak}</div>
|
||||||
</DetailsTable.Row>
|
</DetailsTable.Row>
|
||||||
|
|
||||||
{!!health.Log && (
|
{!!health.Log?.length && (
|
||||||
<DetailsTable.Row label="Last output">
|
<DetailsTable.Row label="Last output">
|
||||||
{health.Log[health.Log.length - 1].Output}
|
{health.Log[health.Log.length - 1].Output}
|
||||||
</DetailsTable.Row>
|
</DetailsTable.Row>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
|
|||||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||||
import { trimContainerName } from '@/docker/filters/utils';
|
import { trimContainerName } from '@/docker/filters/utils';
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
|
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
@@ -32,7 +32,7 @@ export function ItemView() {
|
|||||||
{ select: (c) => new ContainerDetailsViewModel(c) }
|
{ select: (c) => new ContainerDetailsViewModel(c) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const registriesQuery = useRegistries();
|
const registriesQuery = useEnvironmentRegistries(environmentId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
containerQuery.isLoading ||
|
containerQuery.isLoading ||
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function TasksDatatable({
|
|||||||
search={search}
|
search={search}
|
||||||
aria-label="Tasks table"
|
aria-label="Tasks table"
|
||||||
data-cy="docker-service-tasks-nested-datatable"
|
data-cy="docker-service-tasks-nested-datatable"
|
||||||
|
initialSortBy={{ id: 'Updated', desc: true }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function StackEditorTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const initialValues: StackEditorFormValues = {
|
const initialValues: StackEditorFormValues = {
|
||||||
environmentVariables: stack.Env,
|
environmentVariables: stack.Env || [],
|
||||||
prune: !!(stack.Option && stack.Option.Prune),
|
prune: !!(stack.Option && stack.Option.Prune),
|
||||||
stackFileContent: originalFileContent,
|
stackFileContent: originalFileContent,
|
||||||
enabledWebhook: !!stack.Webhook,
|
enabledWebhook: !!stack.Webhook,
|
||||||
@@ -117,6 +117,7 @@ export function StackEditorTab({
|
|||||||
envType={envType}
|
envType={envType}
|
||||||
schema={schemaQuery.data}
|
schema={schemaQuery.data}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
|
isSubmitting={mutation.isLoading}
|
||||||
isSaved={mutation.isSuccess}
|
isSaved={mutation.isSuccess}
|
||||||
webhookId={webhookId}
|
webhookId={webhookId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const defaultProps = {
|
|||||||
schema: { type: 'object' } as JSONSchema7,
|
schema: { type: 'object' } as JSONSchema7,
|
||||||
isOrphaned: false,
|
isOrphaned: false,
|
||||||
stackId: 1,
|
stackId: 1,
|
||||||
|
isSubmitting: false,
|
||||||
isSaved: false,
|
isSaved: false,
|
||||||
webhookId: '',
|
webhookId: '',
|
||||||
};
|
};
|
||||||
@@ -377,17 +378,7 @@ describe('form submission', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading text during submission', async () => {
|
it('should show loading text during submission', async () => {
|
||||||
const onSubmit = vi.fn().mockImplementation(() => new Promise(() => {})); // Never resolves
|
renderComponent({ isSubmitting: true }, {});
|
||||||
renderComponent({}, { onSubmit });
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
|
||||||
expect(deployButton).toBeEnabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
|
||||||
await user.click(deployButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument();
|
expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface StackEditorTabInnerProps {
|
|||||||
versions?: Array<number>;
|
versions?: Array<number>;
|
||||||
stackId: Stack['Id'];
|
stackId: Stack['Id'];
|
||||||
isSaved: boolean;
|
isSaved: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
webhookId: string;
|
webhookId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,20 +43,15 @@ export function StackEditorTabInner({
|
|||||||
versions,
|
versions,
|
||||||
stackId,
|
stackId,
|
||||||
isSaved,
|
isSaved,
|
||||||
|
isSubmitting,
|
||||||
webhookId,
|
webhookId,
|
||||||
}: StackEditorTabInnerProps) {
|
}: StackEditorTabInnerProps) {
|
||||||
const { authorized: isAuthorizedToUpdate } = useAuthorizations(
|
const { authorized: isAuthorizedToUpdate } = useAuthorizations(
|
||||||
'PortainerStackUpdate'
|
'PortainerStackUpdate'
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { values, errors, setFieldValue, isValid, initialValues } =
|
||||||
values,
|
useFormikContext<StackEditorFormValues>();
|
||||||
errors,
|
|
||||||
setFieldValue,
|
|
||||||
isSubmitting,
|
|
||||||
isValid,
|
|
||||||
initialValues,
|
|
||||||
} = useFormikContext<StackEditorFormValues>();
|
|
||||||
|
|
||||||
usePreventExit(
|
usePreventExit(
|
||||||
initialValues.stackFileContent,
|
initialValues.stackFileContent,
|
||||||
|
|||||||
+10
-15
@@ -173,24 +173,19 @@ describe('getEnvironmentOptions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create "Unassigned" group for GroupId = 1', () => {
|
it('should auto create an Others group if group is missing', () => {
|
||||||
const environments: Environment[] = [
|
const environments: Environment[] = [
|
||||||
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
|
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
|
||||||
|
{ Id: 2, Name: 'Env 2', GroupId: 2 } as Environment,
|
||||||
];
|
];
|
||||||
|
const groups: EnvironmentGroup[] = [];
|
||||||
|
|
||||||
const result = getEnvironmentOptions([], environments);
|
const result = getEnvironmentOptions(groups, environments);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
expect(result[0].label).toBe('Unassigned');
|
expect(result[0].label).toBe('Others');
|
||||||
expect(result[0].options[0]).toEqual({ label: 'Env 1', value: 1 });
|
expect(result[0].options).toEqual([
|
||||||
});
|
{ label: 'Env 1', value: 1 },
|
||||||
|
{ label: 'Env 2', value: 2 },
|
||||||
it('should throw error if group is missing for non-unassigned GroupId', () => {
|
]);
|
||||||
const environments: Environment[] = [
|
|
||||||
{ Id: 1, Name: 'Env 1', GroupId: 2 } as Environment,
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(() => getEnvironmentOptions([], environments)).toThrow(
|
|
||||||
'Missing group with id 2'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||||
@@ -73,7 +74,11 @@ export function getEnvironmentOptions(
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupId = environment.GroupId;
|
let groupId = environment.GroupId;
|
||||||
|
if (!groups.some((g) => g.Id === groupId)) {
|
||||||
|
groupId = -1;
|
||||||
|
}
|
||||||
|
|
||||||
if (!acc[groupId]) {
|
if (!acc[groupId]) {
|
||||||
acc[groupId] = [];
|
acc[groupId] = [];
|
||||||
}
|
}
|
||||||
@@ -87,13 +92,10 @@ export function getEnvironmentOptions(
|
|||||||
return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => {
|
return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => {
|
||||||
const parsedGroupId = parseInt(groupId, 10);
|
const parsedGroupId = parseInt(groupId, 10);
|
||||||
const group = groups.find((g) => g.Id === parsedGroupId);
|
const group = groups.find((g) => g.Id === parsedGroupId);
|
||||||
if (!group && parsedGroupId !== 1) {
|
|
||||||
throw new Error(`Missing group with id ${groupId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: group?.Name || 'Unassigned',
|
label: group?.Name || 'Others',
|
||||||
options: envOptions,
|
options: sortBy(envOptions, 'label'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ export async function duplicateStack({
|
|||||||
fileContent: string;
|
fileContent: string;
|
||||||
targetEnvironmentId: EnvironmentId;
|
targetEnvironmentId: EnvironmentId;
|
||||||
type: StackType;
|
type: StackType;
|
||||||
env?: Array<Pair>;
|
env?: Array<Pair> | null;
|
||||||
}) {
|
}) {
|
||||||
if (type === StackType.DockerSwarm) {
|
if (type === StackType.DockerSwarm) {
|
||||||
const swarm = await getSwarm(targetEnvironmentId);
|
const swarm = await getSwarm(targetEnvironmentId);
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ export function StackRedeployGitForm({ stack }: { stack: Stack }) {
|
|||||||
SaveCredential: false,
|
SaveCredential: false,
|
||||||
},
|
},
|
||||||
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
|
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
|
||||||
env: stack.Env,
|
env: stack.Env || [],
|
||||||
prune: stack.Option?.Prune || false,
|
prune: stack.Option?.Prune || false,
|
||||||
refName: stack.GitConfig?.ReferenceName || '',
|
refName: stack.GitConfig?.ReferenceName || '',
|
||||||
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
|
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function useUpdateStackMutation() {
|
|||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
stackFileContent: string;
|
stackFileContent: string;
|
||||||
env?: EnvVarValues;
|
env?: EnvVarValues | null;
|
||||||
prune?: boolean;
|
prune?: boolean;
|
||||||
webhook?: string;
|
webhook?: string;
|
||||||
repullImageAndRedeploy?: boolean;
|
repullImageAndRedeploy?: boolean;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async function createStackAndGitCredential(
|
|||||||
autoUpdate: AutoUpdateResponse | null;
|
autoUpdate: AutoUpdateResponse | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const newGitModel = await saveGitCredentialsIfNeeded(userId, payload.git);
|
const resolvedAuth = await saveGitCredentialsIfNeeded(userId, payload.git);
|
||||||
|
|
||||||
return createStackFromGit({
|
return createStackFromGit({
|
||||||
deploymentType: payload.deploymentType,
|
deploymentType: payload.deploymentType,
|
||||||
@@ -132,13 +132,13 @@ async function createStackAndGitCredential(
|
|||||||
retryDeploy: payload.retryDeploy,
|
retryDeploy: payload.retryDeploy,
|
||||||
staggerConfig: payload.staggerConfig,
|
staggerConfig: payload.staggerConfig,
|
||||||
useManifestNamespaces: payload.useManifestNamespaces,
|
useManifestNamespaces: payload.useManifestNamespaces,
|
||||||
repositoryUrl: newGitModel.RepositoryURL,
|
repositoryUrl: payload.git.RepositoryURL,
|
||||||
repositoryReferenceName: newGitModel.RepositoryReferenceName,
|
repositoryReferenceName: payload.git.RepositoryReferenceName,
|
||||||
filePathInRepository: newGitModel.ComposeFilePathInRepository,
|
filePathInRepository: payload.git.ComposeFilePathInRepository,
|
||||||
repositoryAuthentication: newGitModel.RepositoryAuthentication,
|
repositoryAuthentication: resolvedAuth.RepositoryAuthentication,
|
||||||
repositoryUsername: newGitModel.RepositoryUsername,
|
repositoryUsername: resolvedAuth.RepositoryUsername,
|
||||||
repositoryPassword: newGitModel.RepositoryPassword,
|
repositoryPassword: resolvedAuth.RepositoryPassword,
|
||||||
repositoryGitCredentialId: newGitModel.RepositoryGitCredentialID,
|
repositoryGitCredentialId: resolvedAuth.RepositoryGitCredentialID,
|
||||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||||
perDeviceConfigsGroupMatchType:
|
perDeviceConfigsGroupMatchType:
|
||||||
@@ -146,7 +146,7 @@ async function createStackAndGitCredential(
|
|||||||
perDeviceConfigsMatchType:
|
perDeviceConfigsMatchType:
|
||||||
payload.relativePathSettings?.PerDeviceConfigsMatchType,
|
payload.relativePathSettings?.PerDeviceConfigsMatchType,
|
||||||
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
|
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
|
||||||
tlsSkipVerify: newGitModel.TLSSkipVerify,
|
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||||
autoUpdate: payload.autoUpdate,
|
autoUpdate: payload.autoUpdate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-34
@@ -1,14 +1,11 @@
|
|||||||
import { Pencil } from 'lucide-react';
|
|
||||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
import { Pod } from 'kubernetes-types/core/v1';
|
import { Pod } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
import { Authorized, useIsEdgeAdmin } from '@/react/hooks/useUser';
|
import { Authorized } from '@/react/hooks/useUser';
|
||||||
import { useNamespaceQuery } from '@/react/kubernetes/namespaces/queries/useNamespaceQuery';
|
import { useNamespaceQuery } from '@/react/kubernetes/namespaces/queries/useNamespaceQuery';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '@@/Widget';
|
import { Widget, WidgetBody } from '@@/Widget';
|
||||||
import { AddButton, Button } from '@@/buttons';
|
import { AddButton } from '@@/buttons';
|
||||||
import { Link } from '@@/Link';
|
|
||||||
import { Icon } from '@@/Icon';
|
|
||||||
|
|
||||||
import { applicationIsKind, isExternalApplication } from '../../utils';
|
import { applicationIsKind, isExternalApplication } from '../../utils';
|
||||||
import { appStackIdLabel, appStackKindLabel } from '../../constants';
|
import { appStackIdLabel, appStackKindLabel } from '../../constants';
|
||||||
@@ -17,6 +14,8 @@ import { useApplicationServices } from '../../queries/useApplicationServices';
|
|||||||
import { useAppStackFile } from '../../queries/useAppStackFile';
|
import { useAppStackFile } from '../../queries/useAppStackFile';
|
||||||
import { Application } from '../../types';
|
import { Application } from '../../types';
|
||||||
|
|
||||||
|
import { EdgeEditButton } from './EdgeEditButton';
|
||||||
|
import { EditButton } from './EditButton';
|
||||||
import { RestartApplicationButton } from './RestartApplicationButton';
|
import { RestartApplicationButton } from './RestartApplicationButton';
|
||||||
import { RedeployApplicationButton } from './RedeployApplicationButton';
|
import { RedeployApplicationButton } from './RedeployApplicationButton';
|
||||||
import { RollbackApplicationButton } from './RollbackApplicationButton';
|
import { RollbackApplicationButton } from './RollbackApplicationButton';
|
||||||
@@ -42,9 +41,6 @@ export function ApplicationDetailsWidget() {
|
|||||||
const namespaceData = useNamespaceQuery(environmentId, namespace);
|
const namespaceData = useNamespaceQuery(environmentId, namespace);
|
||||||
const isSystemNamespace = namespaceData.data?.IsSystem;
|
const isSystemNamespace = namespaceData.data?.IsSystem;
|
||||||
|
|
||||||
// check if user is edge admin
|
|
||||||
const edgeAdminQuery = useIsEdgeAdmin();
|
|
||||||
|
|
||||||
// get app info
|
// get app info
|
||||||
const { data: app } = useApplication(
|
const { data: app } = useApplication(
|
||||||
environmentId,
|
environmentId,
|
||||||
@@ -81,35 +77,15 @@ export function ApplicationDetailsWidget() {
|
|||||||
{!isSystemNamespace && (
|
{!isSystemNamespace && (
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<Authorized authorizations="K8sApplicationDetailsW">
|
<Authorized authorizations="K8sApplicationDetailsW">
|
||||||
<Link
|
{appStackKind === 'edge' ? (
|
||||||
to={
|
<EdgeEditButton stackId={appStackId} />
|
||||||
appStackKind === 'edge'
|
) : (
|
||||||
? 'edge.stacks.edit'
|
<EditButton to=".edit">
|
||||||
: 'kubernetes.applications.application.edit'
|
|
||||||
}
|
|
||||||
params={
|
|
||||||
appStackKind === 'edge'
|
|
||||||
? { stackId: appStackId }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
data-cy="k8sAppDetail-editAppLink"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="light"
|
|
||||||
size="small"
|
|
||||||
className="hover:decoration-none !ml-0"
|
|
||||||
data-cy="k8sAppDetail-editAppButton"
|
|
||||||
disabled={
|
|
||||||
edgeAdminQuery.isLoading || !edgeAdminQuery.isAdmin
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon icon={Pencil} className="mr-1" />
|
|
||||||
{externalApp
|
{externalApp
|
||||||
? 'Edit external application'
|
? 'Edit external application'
|
||||||
: 'Edit this application'}
|
: 'Edit this application'}
|
||||||
</Button>
|
</EditButton>
|
||||||
</Link>
|
)}
|
||||||
</Authorized>
|
</Authorized>
|
||||||
{!applicationIsKind<Pod>('Pod', app) && (
|
{!applicationIsKind<Pod>('Pod', app) && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { EdgeEditButton } from './EdgeEditButton';
|
||||||
|
|
||||||
|
const mockUseIsEdgeAdmin = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useUser', () => ({
|
||||||
|
useIsEdgeAdmin: () => mockUseIsEdgeAdmin(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@@/Tip/TooltipWithChildren', () => ({
|
||||||
|
TooltipWithChildren: ({
|
||||||
|
children,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
message: string;
|
||||||
|
}) => (
|
||||||
|
<div data-cy="tooltip" data-message={message}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@@/buttons', () => ({
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
'data-cy': dataCy,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
'data-cy'?: string;
|
||||||
|
}) => (
|
||||||
|
<button disabled={disabled} data-cy={dataCy} type="button">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@@/Link', () => ({
|
||||||
|
Link: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<a href="/">{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EdgeEditButton', () => {
|
||||||
|
it('renders disabled button without tooltip while loading', () => {
|
||||||
|
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: true, isAdmin: false });
|
||||||
|
|
||||||
|
render(<EdgeEditButton stackId={1} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders enabled button without tooltip for edge admin', () => {
|
||||||
|
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: false, isAdmin: true });
|
||||||
|
|
||||||
|
render(<EdgeEditButton stackId={1} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders disabled button with tooltip for non-admin', () => {
|
||||||
|
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: false, isAdmin: false });
|
||||||
|
|
||||||
|
render(<EdgeEditButton stackId={1} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
|
||||||
|
const tooltip = screen.getByTestId('tooltip');
|
||||||
|
expect(tooltip).toHaveAttribute(
|
||||||
|
'data-message',
|
||||||
|
'This application is managed by an edge stack and can only be edited by an edge administrator'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||||
|
|
||||||
|
import { EditButton } from './EditButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stackId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EdgeEditButton({ stackId }: Props) {
|
||||||
|
const edgeAdminQuery = useIsEdgeAdmin();
|
||||||
|
|
||||||
|
const isDisabled = edgeAdminQuery.isLoading || !edgeAdminQuery.isAdmin;
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<EditButton
|
||||||
|
to="edge.stacks.edit"
|
||||||
|
params={{ stackId }}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
Manage edge stack
|
||||||
|
</EditButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (edgeAdminQuery.isLoading || edgeAdminQuery.isAdmin) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipWithChildren message="This application is managed by an edge stack and can only be edited by an edge administrator">
|
||||||
|
<span>{button}</span>
|
||||||
|
</TooltipWithChildren>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PencilIcon } from 'lucide-react';
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
to?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditButton({
|
||||||
|
to = '',
|
||||||
|
params,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="light"
|
||||||
|
size="small"
|
||||||
|
data-cy="k8sAppDetail-editAppButton"
|
||||||
|
disabled={disabled}
|
||||||
|
as={disabled ? 'button' : Link}
|
||||||
|
props={disabled ? undefined : { to, params }}
|
||||||
|
icon={PencilIcon}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-5
@@ -2,7 +2,10 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { GitAuthModel } from '@/react/portainer/gitops/types';
|
import {
|
||||||
|
GitAuthModel,
|
||||||
|
GitCredentialsModel,
|
||||||
|
} from '@/react/portainer/gitops/types';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ export function useSaveCredentialsIfRequired() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
|
export async function saveGitCredentialsIfNeeded(
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
gitModel: TGit
|
gitModel: GitAuthModel
|
||||||
) {
|
): Promise<GitCredentialsModel> {
|
||||||
let credentialsId = gitModel.RepositoryGitCredentialID;
|
let credentialsId = gitModel.RepositoryGitCredentialID;
|
||||||
let username = gitModel.RepositoryUsername;
|
let username = gitModel.RepositoryUsername;
|
||||||
let password = gitModel.RepositoryPassword;
|
let password = gitModel.RepositoryPassword;
|
||||||
@@ -112,9 +115,10 @@ export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...gitModel,
|
RepositoryAuthentication: gitModel.RepositoryAuthentication,
|
||||||
RepositoryGitCredentialID: credentialsId,
|
RepositoryGitCredentialID: credentialsId,
|
||||||
RepositoryUsername: username,
|
RepositoryUsername: username,
|
||||||
RepositoryPassword: password,
|
RepositoryPassword: password,
|
||||||
|
RepositoryAuthorizationType: gitModel.RepositoryAuthorizationType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function CreateGroupView() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row pb-20">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
<Widget.Body>
|
<Widget.Body>
|
||||||
|
|||||||
+5
-23
@@ -298,14 +298,13 @@ describe('EditGroupView', () => {
|
|||||||
|
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
|
|
||||||
// Verify the request URL and body
|
// Verify the request URL and body.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestUrl).toBe('/api/endpoint_groups/2');
|
expect(requestUrl).toBe('/api/endpoint_groups/2');
|
||||||
expect(requestBody).toEqual({
|
expect(requestBody).toEqual({
|
||||||
Name: 'Updated Group',
|
Name: 'Updated Group',
|
||||||
Description: 'Test description',
|
Description: 'Test description',
|
||||||
TagIDs: [1],
|
TagIDs: [1],
|
||||||
AssociatedEndpoints: [1], // The associated environment ID
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -451,22 +450,9 @@ describe('EditGroupView', () => {
|
|||||||
expect(elements[0]).toBeVisible();
|
expect(elements[0]).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include associated environment IDs in update payload', async () => {
|
it('should NOT include AssociatedEndpoints in update payload (backend preserves associations)', async () => {
|
||||||
let requestBody: DefaultBodyType;
|
let requestBody: DefaultBodyType;
|
||||||
|
|
||||||
const associatedEnvs = [
|
|
||||||
{
|
|
||||||
...mockEnvironment,
|
|
||||||
Id: 100,
|
|
||||||
Name: 'Env 100',
|
|
||||||
} as Partial<Environment>,
|
|
||||||
{
|
|
||||||
...mockEnvironment,
|
|
||||||
Id: 200,
|
|
||||||
Name: 'Env 200',
|
|
||||||
} as Partial<Environment>,
|
|
||||||
];
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.put('/api/endpoint_groups/:id', async ({ request }) => {
|
http.put('/api/endpoint_groups/:id', async ({ request }) => {
|
||||||
requestBody = await request.json();
|
requestBody = await request.json();
|
||||||
@@ -475,9 +461,7 @@ describe('EditGroupView', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderEditGroupView({
|
renderEditGroupView();
|
||||||
associatedEnvironments: associatedEnvs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for form to populate
|
// Wait for form to populate
|
||||||
const nameInput = await screen.findByLabelText(/Name/i);
|
const nameInput = await screen.findByLabelText(/Name/i);
|
||||||
@@ -495,11 +479,9 @@ describe('EditGroupView', () => {
|
|||||||
|
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
|
|
||||||
// Verify the associated environments are included in payload
|
// Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestBody).toMatchObject({
|
expect(requestBody).not.toHaveProperty('AssociatedEndpoints');
|
||||||
AssociatedEndpoints: [100, 200],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useRouter } from '@uirouter/react';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FormikHelpers } from 'formik';
|
import { FormikHelpers } from 'formik';
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
|
||||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
@@ -13,32 +11,22 @@ import { Alert } from '@@/Alert';
|
|||||||
import { useGroup } from '../queries/useGroup';
|
import { useGroup } from '../queries/useGroup';
|
||||||
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
||||||
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
||||||
|
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
||||||
|
|
||||||
export function EditGroupView() {
|
export function EditGroupView() {
|
||||||
const groupId = useIdParam();
|
const groupId = useIdParam();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const groupQuery = useGroup(groupId);
|
const groupQuery = useGroup(groupId);
|
||||||
const updateMutation = useUpdateGroupMutation();
|
|
||||||
|
|
||||||
// Fetch associated environments for this group (not for unassigned group)
|
|
||||||
const isUnassignedGroup = groupId === 1;
|
const isUnassignedGroup = groupId === 1;
|
||||||
const environmentsQuery = useEnvironmentList(
|
const updateMutation = useUpdateGroupMutation();
|
||||||
{ groupIds: [groupId], pageLimit: 0 },
|
|
||||||
{ enabled: !!groupId && !isUnassignedGroup }
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
|
|
||||||
|
|
||||||
const initialValues: GroupFormValues = useMemo(
|
const initialValues: GroupFormValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
name: groupQuery.data?.Name ?? '',
|
name: groupQuery.data?.Name ?? '',
|
||||||
description: groupQuery.data?.Description ?? '',
|
description: groupQuery.data?.Description ?? '',
|
||||||
tagIds: groupQuery.data?.TagIds ?? [],
|
tagIds: groupQuery.data?.TagIds ?? [],
|
||||||
associatedEnvironments:
|
|
||||||
environmentsQuery.environments?.map((e) => e.Id) ?? [],
|
|
||||||
}),
|
}),
|
||||||
[groupQuery.data, environmentsQuery.environments]
|
[groupQuery.data]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +42,7 @@ export function EditGroupView() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
<Widget.Body loading={isLoading}>
|
<Widget.Body loading={groupQuery.isLoading}>
|
||||||
{groupQuery.isError && (
|
{groupQuery.isError && (
|
||||||
<Alert color="error" title="Error">
|
<Alert color="error" title="Error">
|
||||||
Failed to load group details
|
Failed to load group details
|
||||||
@@ -73,6 +61,15 @@ export function EditGroupView() {
|
|||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="row pb-20">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<AssociatedEnvironmentsSelector
|
||||||
|
groupId={groupId}
|
||||||
|
readOnly={isUnassignedGroup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,12 +83,11 @@ export function EditGroupView() {
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
tagIds: values.tagIds,
|
tagIds: values.tagIds,
|
||||||
associatedEnvironments: values.associatedEnvironments,
|
// associatedEnvironments omitted — backend preserves existing when field is absent (nil)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
resetForm();
|
resetForm();
|
||||||
notifySuccess('Success', 'Group successfully updated');
|
|
||||||
router.stateService.go('portainer.groups');
|
router.stateService.go('portainer.groups');
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
import { truncate } from 'lodash';
|
||||||
|
|
||||||
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||||
|
import {
|
||||||
|
EnvironmentId,
|
||||||
|
EnvironmentGroupId,
|
||||||
|
} from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { Datatable } from '@@/datatables';
|
||||||
|
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||||
|
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
|
||||||
|
import { TableRow } from '@@/datatables/TableRow';
|
||||||
|
import { Sheet, SheetContent, SheetClose, SheetHeader } from '@@/Sheet';
|
||||||
|
import { Button, LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
|
import { EnvironmentTableData } from './types';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<EnvironmentTableData>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('Name', {
|
||||||
|
header: 'Name',
|
||||||
|
id: 'Name',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose(): void;
|
||||||
|
/** IDs already in the group — excluded from the available list. Use for create-form contexts where no group ID exists yet. */
|
||||||
|
excludeIds?: EnvironmentId[];
|
||||||
|
/** Endpoint group IDs whose members are excluded from the available list. Prefer this over excludeIds when a group ID is available, to avoid sending thousands of individual IDs in the URL. */
|
||||||
|
excludeGroupIds?: EnvironmentGroupId[];
|
||||||
|
/** Called with the full env objects so callers can display names or extract IDs.
|
||||||
|
* Returns true if the add was committed, false if the user cancelled. */
|
||||||
|
onAdd:
|
||||||
|
| ((envs: EnvironmentTableData[]) => Promise<boolean>)
|
||||||
|
| ((envs: EnvironmentTableData[]) => void);
|
||||||
|
/** Loading state from the parent — disables buttons and shows spinner */
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddEnvironmentsDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
excludeIds,
|
||||||
|
excludeGroupIds,
|
||||||
|
onAdd,
|
||||||
|
isLoading,
|
||||||
|
}: Props) {
|
||||||
|
const tableState = useTableStateWithoutStorage('Name');
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [selectedEnvs, setSelectedEnvs] = useState<EnvironmentTableData[]>([]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
environments,
|
||||||
|
totalCount,
|
||||||
|
isLoading: isEnvsLoading,
|
||||||
|
} = useEnvironmentList({
|
||||||
|
pageLimit: tableState.pageSize,
|
||||||
|
page: page + 1,
|
||||||
|
search: tableState.search,
|
||||||
|
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
|
||||||
|
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
||||||
|
groupIds: [1],
|
||||||
|
excludeIds,
|
||||||
|
excludeGroupIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSelectionChange(ids: string[]) {
|
||||||
|
setSelectedIds(ids);
|
||||||
|
const currentDataMap = new Map(
|
||||||
|
(environments ?? []).map((env) => [String(env.Id), env])
|
||||||
|
);
|
||||||
|
setSelectedEnvs((prev) => {
|
||||||
|
const prevMap = new Map(prev.map((e) => [String(e.Id), e]));
|
||||||
|
// Keep already-tracked envs that remain selected
|
||||||
|
const kept = prev.filter((e) => ids.includes(String(e.Id)));
|
||||||
|
// Add newly selected envs from the current page
|
||||||
|
const added = ids
|
||||||
|
.filter((id) => !prevMap.has(id) && currentDataMap.has(id))
|
||||||
|
.map((id) => currentDataMap.get(id)!);
|
||||||
|
return [...kept, ...added];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const committed = await onAdd(selectedEnvs);
|
||||||
|
// Close only if the add was committed or there was no confirmation needed
|
||||||
|
if (committed || committed === undefined) {
|
||||||
|
resetSelection();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSelection() {
|
||||||
|
setSelectedIds([]);
|
||||||
|
setSelectedEnvs([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
resetSelection();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SheetContent className="flex flex-col !p-0">
|
||||||
|
<div className="flex-1 p-4 overflow-auto">
|
||||||
|
<SheetHeader title="Add environments" />
|
||||||
|
<Datatable<EnvironmentTableData>
|
||||||
|
title="Available environments"
|
||||||
|
columns={columns}
|
||||||
|
dataset={environments ?? []}
|
||||||
|
settingsManager={tableState}
|
||||||
|
isLoading={isEnvsLoading}
|
||||||
|
isServerSidePagination
|
||||||
|
page={page}
|
||||||
|
onPageChange={setPage}
|
||||||
|
totalCount={totalCount}
|
||||||
|
getRowId={(row) => String(row.Id)}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<TableRow<EnvironmentTableData>
|
||||||
|
cells={row.getVisibleCells()}
|
||||||
|
onClick={() => row.toggleSelected()}
|
||||||
|
className={clsx({ active: row.getIsSelected() })}
|
||||||
|
aria-selected={row.getIsSelected()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
extendTableOptions={withControlledSelected(
|
||||||
|
handleSelectionChange,
|
||||||
|
selectedIds
|
||||||
|
)}
|
||||||
|
data-cy="add-environments-drawer-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Don't use StickyFooter here. StickyFooter has classes for the menu to the left that we don't want here */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'bottom-0 left-0 right-0 w-full z-50 h-16 sticky justify-end gap-4',
|
||||||
|
'flex items-center px-6',
|
||||||
|
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
|
||||||
|
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetClose asChild>
|
||||||
|
<Button
|
||||||
|
color="default"
|
||||||
|
disabled={isLoading}
|
||||||
|
data-cy="add-environments-cancel-button"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</SheetClose>
|
||||||
|
<LoadingButton
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={selectedIds.length === 0 || isLoading}
|
||||||
|
isLoading={!!isLoading}
|
||||||
|
loadingText="Adding..."
|
||||||
|
data-cy="add-environments-confirm-button"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
+140
-163
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
@@ -11,227 +11,204 @@ import {
|
|||||||
EnvironmentId,
|
EnvironmentId,
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../../types';
|
||||||
|
|
||||||
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
|
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
|
||||||
|
|
||||||
function createEnv(id: EnvironmentId, name: string): Environment {
|
vi.mock('@@/modals/confirm', () => ({
|
||||||
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 });
|
openConfirm: vi.fn().mockResolvedValue(true),
|
||||||
|
confirmDelete: vi.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGroup: EnvironmentGroup = {
|
||||||
|
Id: 2,
|
||||||
|
Name: 'Test Group',
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createEnv(id: EnvironmentId, name: string): Partial<Environment> {
|
||||||
|
return createMockEnvironment({ Id: id, Name: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupMockServer(environments: Array<Environment> = []) {
|
function setupMockServer({
|
||||||
|
associatedEnvs = [] as Array<Partial<Environment>>,
|
||||||
|
availableEnvs = [] as Array<Partial<Environment>>,
|
||||||
|
onPut = undefined as ((body: unknown) => void) | undefined,
|
||||||
|
} = {}) {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints', () =>
|
http.get('/api/endpoint_groups/2', () => HttpResponse.json(mockGroup)),
|
||||||
HttpResponse.json(environments, {
|
http.get('/api/endpoints', ({ request }) => {
|
||||||
headers: {
|
const url = new URL(request.url);
|
||||||
'x-total-count': String(environments.length),
|
const groupIds = [
|
||||||
'x-total-available': String(environments.length),
|
...url.searchParams.getAll('groupIds'),
|
||||||
},
|
...url.searchParams.getAll('groupIds[]'),
|
||||||
})
|
];
|
||||||
)
|
|
||||||
|
function makeResponse(envs: Array<Partial<Environment>>) {
|
||||||
|
return HttpResponse.json(envs, {
|
||||||
|
headers: {
|
||||||
|
'x-total-count': String(envs.length),
|
||||||
|
'x-total-available': String(envs.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupIds.includes('2')) return makeResponse(associatedEnvs);
|
||||||
|
if (groupIds.includes('1')) return makeResponse(availableEnvs);
|
||||||
|
return makeResponse([]);
|
||||||
|
}),
|
||||||
|
http.put('/api/endpoint_groups/2', async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
onPut?.(body);
|
||||||
|
return HttpResponse.json(mockGroup);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponent({
|
function renderComponent(groupId = 2) {
|
||||||
associatedEnvironmentIds = [] as Array<EnvironmentId>,
|
|
||||||
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
|
|
||||||
onChange = vi.fn(),
|
|
||||||
}: {
|
|
||||||
associatedEnvironmentIds?: Array<EnvironmentId>;
|
|
||||||
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
|
|
||||||
onChange?: (ids: Array<EnvironmentId>) => void;
|
|
||||||
} = {}) {
|
|
||||||
const Wrapped = withTestQueryProvider(() => (
|
const Wrapped = withTestQueryProvider(() => (
|
||||||
<AssociatedEnvironmentsSelector
|
<AssociatedEnvironmentsSelector groupId={groupId} readOnly={false} />
|
||||||
associatedEnvironmentIds={associatedEnvironmentIds}
|
|
||||||
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
|
|
||||||
return {
|
return render(<Wrapped />);
|
||||||
...render(<Wrapped />),
|
|
||||||
onChange,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AssociatedEnvironmentsSelector', () => {
|
describe('AssociatedEnvironmentsSelector', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render both Available and Associated environments tables', async () => {
|
it('renders the associated environments table', async () => {
|
||||||
setupMockServer();
|
setupMockServer();
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'Available environments' })
|
await screen.findByRole('heading', { name: 'Associated environments' })
|
||||||
).toBeVisible();
|
|
||||||
expect(
|
|
||||||
screen.getByRole('heading', { name: 'Associated environments' })
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render instruction text', async () => {
|
it('renders an Add button', async () => {
|
||||||
setupMockServer();
|
setupMockServer();
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/click on any environment entry to move it/i)
|
await screen.findByTestId('add-environments-button')
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render Associated environments table with data-cy attribute', async () => {
|
it('renders a Remove button that is initially disabled', async () => {
|
||||||
setupMockServer();
|
setupMockServer({ associatedEnvs: [createEnv(10, 'my-env')] });
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
await waitFor(() => {
|
await screen.findByText('my-env');
|
||||||
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
|
|
||||||
});
|
const removeBtn = screen.getByTestId('remove-environments-button');
|
||||||
|
expect(removeBtn).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display initially associated environments in Associated table', async () => {
|
it('displays environments returned by the API', async () => {
|
||||||
const envs = [createEnv(10, 'associated-env-1')];
|
setupMockServer({ associatedEnvs: [createEnv(10, 'env-alpha')] });
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
setupMockServer(envs);
|
expect(await screen.findByText('env-alpha')).toBeInTheDocument();
|
||||||
renderComponent({
|
|
||||||
associatedEnvironmentIds: [10],
|
|
||||||
initialAssociatedEnvironmentIds: [10],
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Adding environments', () => {
|
|
||||||
it('should call onChange with new environment ID when clicking an available environment', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
|
||||||
|
|
||||||
const envs = [createEnv(1, 'available-env')];
|
|
||||||
setupMockServer(envs);
|
|
||||||
|
|
||||||
renderComponent({ onChange });
|
|
||||||
|
|
||||||
const envRow = await screen.findByText('available-env');
|
|
||||||
await user.click(envRow);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should append new environment to existing associated IDs', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onChange = vi.fn();
|
|
||||||
|
|
||||||
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
|
|
||||||
setupMockServer(envs);
|
|
||||||
|
|
||||||
renderComponent({
|
|
||||||
associatedEnvironmentIds: [10],
|
|
||||||
initialAssociatedEnvironmentIds: [10],
|
|
||||||
onChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the available table to be ready and find the row
|
|
||||||
const availableTable = await screen.findByTestId(
|
|
||||||
'group-availableEndpoints'
|
|
||||||
);
|
|
||||||
await within(availableTable).findByText('available-env');
|
|
||||||
|
|
||||||
// Find the row element that contains the text and click it
|
|
||||||
const rows = within(availableTable).getAllByRole('row');
|
|
||||||
const envRow = rows.find(
|
|
||||||
(row) => row.textContent?.includes('available-env')
|
|
||||||
);
|
|
||||||
expect(envRow).toBeDefined();
|
|
||||||
await user.click(envRow!);
|
|
||||||
|
|
||||||
// Wait for onChange to be called with the new environment ID appended
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(onChange).toHaveBeenCalledWith([10, 1]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Removing environments', () => {
|
describe('Removing environments', () => {
|
||||||
it('should call onChange without the removed environment ID', async () => {
|
it('enables Remove button when a row is selected', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
setupMockServer({ associatedEnvs: [createEnv(10, 'env-to-remove')] });
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
const envs = [
|
await screen.findByText('env-to-remove');
|
||||||
createEnv(10, 'associated-env-1'),
|
|
||||||
createEnv(11, 'associated-env-2'),
|
|
||||||
];
|
|
||||||
setupMockServer(envs);
|
|
||||||
|
|
||||||
renderComponent({
|
// First checkbox is the select-all header, second is the first row
|
||||||
associatedEnvironmentIds: [10, 11],
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
initialAssociatedEnvironmentIds: [10, 11],
|
await user.click(checkboxes[1]);
|
||||||
onChange,
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('remove-environments-button')).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for initial query to load and row to appear in Associated table, then click
|
|
||||||
const associatedTable = await screen.findByTestId(
|
|
||||||
'group-associatedEndpoints'
|
|
||||||
);
|
|
||||||
const envRow =
|
|
||||||
await within(associatedTable).findByText('associated-env-1');
|
|
||||||
await user.click(envRow);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith([11]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onChange with empty array when removing last environment', async () => {
|
it('calls PUT with filtered environment IDs after confirming remove', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
let requestBody: unknown;
|
||||||
|
|
||||||
const envs = [createEnv(10, 'only-env')];
|
setupMockServer({
|
||||||
setupMockServer(envs);
|
associatedEnvs: [createEnv(10, 'env-a'), createEnv(11, 'env-b')],
|
||||||
|
onPut: (body) => {
|
||||||
renderComponent({
|
requestBody = body;
|
||||||
associatedEnvironmentIds: [10],
|
},
|
||||||
initialAssociatedEnvironmentIds: [10],
|
|
||||||
onChange,
|
|
||||||
});
|
});
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
const associatedTable = await screen.findByTestId(
|
await screen.findByText('env-a');
|
||||||
'group-associatedEndpoints'
|
|
||||||
);
|
|
||||||
const envRow = await within(associatedTable).findByText('only-env');
|
|
||||||
await user.click(envRow);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith([]);
|
// Select first row checkbox
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
await user.click(checkboxes[1]);
|
||||||
|
|
||||||
|
const removeBtn = await screen.findByTestId('remove-environments-button');
|
||||||
|
await waitFor(() => expect(removeBtn).toBeEnabled());
|
||||||
|
await user.click(removeBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
AssociatedEndpoints: [11],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Computed values', () => {
|
describe('Adding environments (drawer)', () => {
|
||||||
it('should identify added IDs (current but not initial)', () => {
|
it('opens the drawer when Add button is clicked', async () => {
|
||||||
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id))
|
const user = userEvent.setup();
|
||||||
// When current=[1,2,3] and initial=[2,3], added=[1]
|
setupMockServer({ availableEnvs: [createEnv(20, 'available-env')] });
|
||||||
setupMockServer();
|
renderComponent();
|
||||||
|
|
||||||
// This test validates the component's internal logic by checking the highlightIds
|
const addBtn = await screen.findByTestId('add-environments-button');
|
||||||
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge)
|
await user.click(addBtn);
|
||||||
renderComponent({
|
|
||||||
associatedEnvironmentIds: [1, 2, 3],
|
|
||||||
initialAssociatedEnvironmentIds: [2, 3],
|
|
||||||
});
|
|
||||||
|
|
||||||
// The component will compute addedIds=[1] internally
|
expect(await screen.findByText('Add environments')).toBeVisible();
|
||||||
// We can't directly test internal state, but we verify it renders
|
|
||||||
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should identify removed IDs (initial but not current)', () => {
|
it('calls PUT with merged IDs when environments are added from drawer', async () => {
|
||||||
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id))
|
const user = userEvent.setup();
|
||||||
// When current=[2,3] and initial=[1,2,3], removed=[1]
|
let requestBody: unknown;
|
||||||
setupMockServer();
|
|
||||||
|
|
||||||
renderComponent({
|
setupMockServer({
|
||||||
associatedEnvironmentIds: [2, 3],
|
associatedEnvs: [createEnv(10, 'existing-env')],
|
||||||
initialAssociatedEnvironmentIds: [1, 2, 3],
|
availableEnvs: [createEnv(20, 'new-env')],
|
||||||
|
onPut: (body) => {
|
||||||
|
requestBody = body;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
// The component will compute removedIds=[1] internally
|
// Open drawer
|
||||||
// and pass it as includeIds to AvailableEnvironmentsTable
|
const addBtn = await screen.findByTestId('add-environments-button');
|
||||||
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible();
|
await user.click(addBtn);
|
||||||
|
|
||||||
|
// Wait for drawer to open and available env to appear
|
||||||
|
await screen.findByText('Add environments');
|
||||||
|
await screen.findByText('new-env');
|
||||||
|
|
||||||
|
// Select the available env — find the drawer's checkboxes
|
||||||
|
// The drawer table has its own checkboxes after the main table ones
|
||||||
|
const allCheckboxes = screen.getAllByRole('checkbox');
|
||||||
|
// Last checkbox belongs to the drawer table row
|
||||||
|
await user.click(allCheckboxes[allCheckboxes.length - 1]);
|
||||||
|
|
||||||
|
// Click the Add button in the drawer footer
|
||||||
|
const confirmAddBtn = screen.getByTestId(
|
||||||
|
'add-environments-confirm-button'
|
||||||
|
);
|
||||||
|
await user.click(confirmAddBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
AssociatedEndpoints: expect.arrayContaining([10, 20]),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+72
-104
@@ -1,126 +1,94 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { openConfirm } from '@@/modals/confirm';
|
||||||
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
|
||||||
import { Environment, EnvironmentId } from '../../../types';
|
import { useGroup } from '../../queries/useGroup';
|
||||||
|
import { useUpdateGroupMutation } from '../../queries/useUpdateGroupMutation';
|
||||||
|
|
||||||
import { EnvironmentTableData } from './types';
|
import { EnvironmentTableData } from './types';
|
||||||
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
|
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
|
||||||
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable';
|
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Group ID when editing an existing group */
|
groupId: EnvironmentGroupId;
|
||||||
groupId?: number;
|
/* For unassigned group, don't show the add/remove buttons and hide the checkbox */
|
||||||
/** IDs of currently associated environments */
|
readOnly: boolean;
|
||||||
associatedEnvironmentIds: Array<EnvironmentId>;
|
|
||||||
/** IDs of initially associated environments for tracking unsaved changes */
|
|
||||||
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
|
|
||||||
/** Called when environment IDs change */
|
|
||||||
onChange: (ids: Array<EnvironmentId>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssociatedEnvironmentsSelector({
|
export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
|
||||||
groupId,
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
associatedEnvironmentIds,
|
|
||||||
initialAssociatedEnvironmentIds,
|
|
||||||
onChange,
|
|
||||||
}: Props) {
|
|
||||||
// Track full environment objects for display (populated when clicking rows)
|
|
||||||
const [environmentCache, setEnvironmentCache] = useState<
|
|
||||||
Map<EnvironmentId, EnvironmentTableData>
|
|
||||||
>(new Map());
|
|
||||||
|
|
||||||
// Fetch initially associated environments to populate the cache
|
const groupQuery = useGroup(groupId);
|
||||||
const initialEnvsQuery = useEnvironmentList(
|
const environmentsQuery = useEnvironmentList({
|
||||||
groupId
|
groupIds: [groupId],
|
||||||
? {
|
pageLimit: 0,
|
||||||
groupIds: [groupId],
|
});
|
||||||
pageLimit: 0,
|
const updateMutation = useUpdateGroupMutation();
|
||||||
}
|
|
||||||
: {
|
|
||||||
endpointIds: initialAssociatedEnvironmentIds,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: groupId
|
|
||||||
? groupId !== 1
|
|
||||||
: initialAssociatedEnvironmentIds.length > 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const environmentMap = useMemo(
|
const currentEnvironments = environmentsQuery.environments ?? [];
|
||||||
() => buildEnvironmentMap(environmentCache, initialEnvsQuery.environments),
|
const currentIds = currentEnvironments.map((e) => e.Id);
|
||||||
[environmentCache, initialEnvsQuery.environments]
|
|
||||||
);
|
|
||||||
const associatedSet = new Set(associatedEnvironmentIds);
|
|
||||||
const initialSet = new Set(initialAssociatedEnvironmentIds);
|
|
||||||
|
|
||||||
const addedIds = associatedEnvironmentIds.filter((id) => !initialSet.has(id));
|
|
||||||
const removedIds = initialAssociatedEnvironmentIds.filter(
|
|
||||||
(id) => !associatedSet.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const excludeIdsForAvailableEnvironments = groupId
|
|
||||||
? addedIds
|
|
||||||
: associatedEnvironmentIds;
|
|
||||||
|
|
||||||
const associatedEnvironments = associatedEnvironmentIds
|
|
||||||
.map((id) => environmentMap.get(id))
|
|
||||||
.filter((env): env is Environment => env !== undefined);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSection title="Associated environments">
|
<>
|
||||||
<div className="small text-muted">
|
<AssociatedEnvironmentsTable
|
||||||
You can select which environment should be part of this group by moving
|
title="Associated environments"
|
||||||
them to the associated environments table. Simply click on any
|
environments={currentEnvironments}
|
||||||
environment entry to move it from one table to the other.
|
isLoading={environmentsQuery.isLoading}
|
||||||
</div>
|
onRemove={handleRemove}
|
||||||
|
onOpenAddDrawer={() => setDrawerOpen(true)}
|
||||||
|
isRemoving={updateMutation.isLoading}
|
||||||
|
data-cy="group-associatedEndpoints"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex mt-4 gap-5 items-stretch">
|
<AddEnvironmentsDrawer
|
||||||
<div className="w-1/2 flex flex-col">
|
open={drawerOpen}
|
||||||
<AvailableEnvironmentsTable
|
onClose={() => setDrawerOpen(false)}
|
||||||
title="Available environments"
|
excludeGroupIds={[groupId]}
|
||||||
excludeIds={excludeIdsForAvailableEnvironments}
|
onAdd={handleAdd}
|
||||||
includeIds={removedIds}
|
isLoading={updateMutation.isLoading}
|
||||||
highlightIds={removedIds}
|
/>
|
||||||
onClickRow={handleAddEnvironment}
|
</>
|
||||||
data-cy="group-availableEndpoints"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-1/2 flex flex-col">
|
|
||||||
<AssociatedEnvironmentsTable
|
|
||||||
title="Associated environments"
|
|
||||||
environments={associatedEnvironments}
|
|
||||||
highlightIds={addedIds}
|
|
||||||
onClickRow={handleRemoveEnvironment}
|
|
||||||
data-cy="group-associatedEndpoints"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormSection>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleAddEnvironment(env: EnvironmentTableData) {
|
function handleRemove(selected: EnvironmentTableData[]) {
|
||||||
if (!associatedEnvironmentIds.includes(env.Id)) {
|
const selectedIds = new Set(selected.map((e) => e.Id));
|
||||||
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env));
|
const remainingIds = currentIds.filter((id) => !selectedIds.has(id));
|
||||||
onChange([...associatedEnvironmentIds, env.Id]);
|
|
||||||
}
|
updateMutation.mutate({
|
||||||
|
id: groupId,
|
||||||
|
name: groupQuery.data?.Name ?? '',
|
||||||
|
description: groupQuery.data?.Description,
|
||||||
|
tagIds: groupQuery.data?.TagIds,
|
||||||
|
associatedEnvironments: remainingIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveEnvironment(env: EnvironmentTableData) {
|
async function handleAdd(newEnvs: EnvironmentTableData[]): Promise<boolean> {
|
||||||
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id));
|
const confirmed = await openConfirm({
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message: `Are you sure you want to add the selected environment(s) to this group?`,
|
||||||
|
confirmButton: buildConfirmButton('Add'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return false;
|
||||||
|
|
||||||
|
const mergedIds = [
|
||||||
|
...new Set([...currentIds, ...newEnvs.map((e) => e.Id)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: groupId,
|
||||||
|
name: groupQuery.data?.Name ?? '',
|
||||||
|
description: groupQuery.data?.Description,
|
||||||
|
tagIds: groupQuery.data?.TagIds,
|
||||||
|
associatedEnvironments: mergedIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEnvironmentMap(
|
|
||||||
cache: Map<EnvironmentId, EnvironmentTableData>,
|
|
||||||
envs: Array<Environment> | undefined
|
|
||||||
): Map<EnvironmentId, EnvironmentTableData> {
|
|
||||||
return new Map([
|
|
||||||
...cache.entries(),
|
|
||||||
...(envs ?? []).map(
|
|
||||||
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|||||||
+85
-46
@@ -1,15 +1,17 @@
|
|||||||
import { createColumnHelper } from '@tanstack/react-table';
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { truncate } from 'lodash';
|
import { truncate } from 'lodash';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||||
import { Datatable, TableRow } from '@@/datatables';
|
import { Datatable } from '@@/datatables';
|
||||||
import { Badge } from '@@/Badge';
|
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
|
||||||
import { Widget } from '@@/Widget';
|
import { TableRow } from '@@/datatables/TableRow';
|
||||||
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { EnvironmentTableData } from './types';
|
import { EnvironmentTableData } from './types';
|
||||||
|
|
||||||
@@ -18,64 +20,101 @@ const columnHelper = createColumnHelper<EnvironmentTableData>();
|
|||||||
interface Props extends AutomationTestingProps {
|
interface Props extends AutomationTestingProps {
|
||||||
title: string;
|
title: string;
|
||||||
environments: Array<EnvironmentTableData>;
|
environments: Array<EnvironmentTableData>;
|
||||||
onClickRow?: (env: EnvironmentTableData) => void;
|
onRemove(selected: EnvironmentTableData[]): void;
|
||||||
highlightIds?: Array<EnvironmentId>;
|
onOpenAddDrawer(): void;
|
||||||
|
isRemoving?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** When false, Remove fires immediately without a confirmation dialog (e.g. create mode) */
|
||||||
|
confirmRemove?: boolean;
|
||||||
|
/** When true, don't show the add/remove buttons and hide the checkbox */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssociatedEnvironmentsTable({
|
export function AssociatedEnvironmentsTable({
|
||||||
title,
|
title,
|
||||||
environments,
|
environments,
|
||||||
onClickRow,
|
onRemove,
|
||||||
highlightIds = [],
|
onOpenAddDrawer,
|
||||||
|
isRemoving,
|
||||||
|
isLoading,
|
||||||
|
confirmRemove = true,
|
||||||
|
readOnly = false,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const tableState = useTableStateWithoutStorage('Name');
|
const tableState = useTableStateWithoutStorage('Name');
|
||||||
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const columns = useMemo(() => buildColumns(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget className="flex-1 flex flex-col">
|
// avoid padding issues with the widget
|
||||||
<div
|
<div className="-mx-[15px]">
|
||||||
className={clsx(
|
<Datatable<EnvironmentTableData>
|
||||||
'h-full flex flex-col',
|
disableSelect={readOnly}
|
||||||
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
|
isLoading={isLoading}
|
||||||
'[&_.footer]:!mt-auto'
|
title={title}
|
||||||
|
columns={columns}
|
||||||
|
settingsManager={tableState}
|
||||||
|
dataset={environments}
|
||||||
|
getRowId={(row) => String(row.Id)}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<TableRow<EnvironmentTableData>
|
||||||
|
cells={row.getVisibleCells()}
|
||||||
|
onClick={() => row.toggleSelected()}
|
||||||
|
className={clsx({ active: row.getIsSelected() })}
|
||||||
|
aria-selected={row.getIsSelected()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
|
||||||
<Datatable<EnvironmentTableData>
|
renderTableActions={(selectedItems) =>
|
||||||
// noWidget to avoid padding issues with TableContainer
|
readOnly ? null : (
|
||||||
noWidget
|
<>
|
||||||
title={title}
|
{confirmRemove ? (
|
||||||
columns={columns}
|
<DeleteButton
|
||||||
settingsManager={tableState}
|
disabled={selectedItems.length === 0}
|
||||||
dataset={environments}
|
isLoading={isRemoving}
|
||||||
renderRow={(row) => (
|
confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
|
||||||
<TableRow<EnvironmentTableData>
|
onConfirmed={() => handleRemove(selectedItems)}
|
||||||
cells={row.getVisibleCells()}
|
data-cy="remove-environments-button"
|
||||||
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
type="button"
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
disableSelect
|
<DeleteButton
|
||||||
data-cy={dataCy || 'environment-table'}
|
disabled={selectedItems.length === 0}
|
||||||
/>
|
onClick={() => {
|
||||||
</div>
|
handleRemove(selectedItems);
|
||||||
</Widget>
|
}}
|
||||||
|
data-cy="remove-environments-button"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
onClick={onOpenAddDrawer}
|
||||||
|
data-cy="add-environments-button"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-cy={dataCy || 'environment-table'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleRemove(selectedItems: EnvironmentTableData[]) {
|
||||||
|
onRemove(selectedItems);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumns(highlightIds: Array<EnvironmentId>) {
|
function buildColumns() {
|
||||||
return [
|
return [
|
||||||
columnHelper.accessor('Name', {
|
columnHelper.accessor('Name', {
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
id: 'Name',
|
id: 'Name',
|
||||||
cell: ({ getValue, row }) => (
|
cell: ({ getValue }) => (
|
||||||
<span className="flex items-center gap-2">
|
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
||||||
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
|
||||||
{highlightIds.includes(row.original.Id) && (
|
|
||||||
<Badge type="muted" data-cy="unsaved-badge">
|
|
||||||
Unsaved
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
import { createColumnHelper } from '@tanstack/react-table';
|
|
||||||
import { truncate } from 'lodash';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
|
||||||
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
|
||||||
import { semverCompare } from '@/react/common/semver-utils';
|
|
||||||
|
|
||||||
import { createPersistedStore } from '@@/datatables/types';
|
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
|
||||||
import { Datatable, TableRow } from '@@/datatables';
|
|
||||||
import { Badge } from '@@/Badge';
|
|
||||||
import { Widget } from '@@/Widget';
|
|
||||||
|
|
||||||
import { EnvironmentTableData } from './types';
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<EnvironmentTableData>();
|
|
||||||
|
|
||||||
const tableKey = 'available-environments';
|
|
||||||
const settingsStore = createPersistedStore(tableKey, 'Name');
|
|
||||||
|
|
||||||
interface Props extends AutomationTestingProps {
|
|
||||||
title: string;
|
|
||||||
/** IDs to exclude from the query (environments already associated) */
|
|
||||||
excludeIds: Array<EnvironmentId>;
|
|
||||||
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
|
|
||||||
includeIds?: Array<EnvironmentId>;
|
|
||||||
/** IDs to highlight (unsaved badge) */
|
|
||||||
highlightIds?: Array<EnvironmentId>;
|
|
||||||
onClickRow?: (env: EnvironmentTableData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AvailableEnvironmentsTable({
|
|
||||||
title,
|
|
||||||
excludeIds,
|
|
||||||
includeIds = [],
|
|
||||||
highlightIds = [],
|
|
||||||
onClickRow,
|
|
||||||
'data-cy': dataCy,
|
|
||||||
}: Props) {
|
|
||||||
const tableState = useTableState(settingsStore, tableKey);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const columns = useMemo(
|
|
||||||
() => buildColumns(new Set(highlightIds)),
|
|
||||||
[highlightIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Query unassigned environments (group 1)
|
|
||||||
const unassignedQuery = useEnvironmentList({
|
|
||||||
pageLimit: tableState.pageSize,
|
|
||||||
page: page + 1,
|
|
||||||
search: tableState.search,
|
|
||||||
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
|
|
||||||
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
|
||||||
groupIds: [1],
|
|
||||||
excludeIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query removed environments by ID (these are still in their original group until saved)
|
|
||||||
const removedQuery = useEnvironmentList(
|
|
||||||
{
|
|
||||||
endpointIds: includeIds,
|
|
||||||
search: tableState.search,
|
|
||||||
},
|
|
||||||
{ enabled: includeIds.length > 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Merge results: removed environments + unassigned environments (deduped)
|
|
||||||
const { environments, uniqueRemovedCount } = useMemo(() => {
|
|
||||||
const unassigned = unassignedQuery.environments || [];
|
|
||||||
const removed =
|
|
||||||
includeIds.length > 0 ? removedQuery.environments || [] : [];
|
|
||||||
|
|
||||||
if (removed.length === 0) {
|
|
||||||
return { environments: unassigned, uniqueRemovedCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const unassignedIds = new Set(unassigned.map((e) => e.Id));
|
|
||||||
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
|
|
||||||
|
|
||||||
// Sort combined results by name to maintain order
|
|
||||||
const combined = [...uniqueRemoved, ...unassigned];
|
|
||||||
const isDesc = tableState.sortBy?.desc ?? false;
|
|
||||||
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
|
|
||||||
const sortKey = getSortKey(tableState.sortBy?.id);
|
|
||||||
if (sortKey) {
|
|
||||||
return {
|
|
||||||
environments: combined.sort((a, b) => {
|
|
||||||
const cmp = semverCompare(
|
|
||||||
a[sortKey].toString(),
|
|
||||||
b[sortKey].toString()
|
|
||||||
);
|
|
||||||
return isDesc ? -cmp : cmp;
|
|
||||||
}),
|
|
||||||
uniqueRemovedCount: uniqueRemoved.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { environments: combined, uniqueRemovedCount: uniqueRemoved.length };
|
|
||||||
}, [
|
|
||||||
unassignedQuery.environments,
|
|
||||||
removedQuery.environments,
|
|
||||||
includeIds.length,
|
|
||||||
tableState.sortBy?.desc,
|
|
||||||
tableState.sortBy?.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalCount = unassignedQuery.totalCount + uniqueRemovedCount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Widget className="flex-1 flex flex-col">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'h-full flex flex-col',
|
|
||||||
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
|
|
||||||
'[&_.footer]:!mt-auto'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Datatable<EnvironmentTableData>
|
|
||||||
// noWidget to avoid padding issues with TableContainer
|
|
||||||
noWidget
|
|
||||||
title={title}
|
|
||||||
columns={columns}
|
|
||||||
settingsManager={tableState}
|
|
||||||
dataset={environments}
|
|
||||||
isServerSidePagination
|
|
||||||
page={page}
|
|
||||||
onPageChange={setPage}
|
|
||||||
totalCount={totalCount}
|
|
||||||
renderRow={(row) => (
|
|
||||||
<TableRow<EnvironmentTableData>
|
|
||||||
cells={row.getVisibleCells()}
|
|
||||||
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
disableSelect
|
|
||||||
data-cy={dataCy || 'available-environments-table'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildColumns(highlightIds: Set<EnvironmentId>) {
|
|
||||||
return [
|
|
||||||
columnHelper.accessor('Name', {
|
|
||||||
header: 'Name',
|
|
||||||
id: 'Name',
|
|
||||||
cell: ({ getValue, row }) => (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
|
|
||||||
{highlightIds.has(row.original.Id) && (
|
|
||||||
<Badge type="muted" data-cy="unsaved-badge">
|
|
||||||
Unsaved
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
|
|
||||||
if (!sortId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
switch (sortId) {
|
|
||||||
case 'Name':
|
|
||||||
return 'Name';
|
|
||||||
default:
|
|
||||||
return 'Name';
|
|
||||||
}
|
|
||||||
// extend to other keys as needed
|
|
||||||
}
|
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
|
import { EnvironmentTableData } from './types';
|
||||||
|
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
|
||||||
|
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedIds: EnvironmentId[];
|
||||||
|
onChange(ids: EnvironmentId[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to AssociatedEnvironmentsSelector, but instead of making API calls and getting the environment list from the server, it holds the the selected environments and ids in local state.
|
||||||
|
*
|
||||||
|
* This is because on create, there is no group to add / remove environments from yet.
|
||||||
|
*/
|
||||||
|
export function FormModeEnvironmentsSelector({ selectedIds, onChange }: Props) {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [selectedEnvironments, setSelectedEnvironments] = useState<
|
||||||
|
EnvironmentTableData[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Associate environments">
|
||||||
|
<p className="small text-muted">
|
||||||
|
Assocate environments to this group by clicking the add button below.
|
||||||
|
</p>
|
||||||
|
<AssociatedEnvironmentsTable
|
||||||
|
title="Associated environments"
|
||||||
|
environments={selectedEnvironments}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onOpenAddDrawer={() => setDrawerOpen(true)}
|
||||||
|
confirmRemove={false}
|
||||||
|
data-cy="group-associatedEndpoints"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddEnvironmentsDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleRemove(toRemove: EnvironmentTableData[]) {
|
||||||
|
const removeIds = new Set(toRemove.map((env) => env.Id));
|
||||||
|
setSelectedEnvironments((prev) => prev.filter((e) => !removeIds.has(e.Id)));
|
||||||
|
onChange(selectedIds.filter((id) => !removeIds.has(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd(newEnvs: EnvironmentTableData[]) {
|
||||||
|
const existingIds = new Set(selectedIds);
|
||||||
|
const toAdd = newEnvs.filter((e) => !existingIds.has(e.Id));
|
||||||
|
setSelectedEnvironments((prev) => [...prev, ...toAdd]);
|
||||||
|
onChange([...selectedIds, ...toAdd.map((e) => e.Id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user