Compare commits
4 Commits
develop
...
release/2.45
| Author | SHA1 | Date | |
|---|---|---|---|
| 0841719075 | |||
| 801ea9cffa | |||
| 1c6a224e8d | |||
| b746f737ad |
+10
-2
@@ -87,8 +87,16 @@ func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore datase
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
||||
_, err := datastore.Backup(filesystem.JoinPaths(backupDirPath, dbFileName))
|
||||
return err
|
||||
|
||||
f, err := os.Create(filesystem.JoinPaths(backupDirPath, dbFileName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database backup file: %w", err)
|
||||
}
|
||||
defer logs.CloseAndLogErr(f)
|
||||
|
||||
return filesystem.RunWithTimeout(dbFileName, filesystem.BackupTimeout, func() error {
|
||||
return datastore.BackupTo(f)
|
||||
})
|
||||
}
|
||||
|
||||
func encrypt(path string, passphrase string) (string, error) {
|
||||
|
||||
@@ -7,17 +7,30 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type hangingDataStore struct {
|
||||
dataservices.DataStore
|
||||
block chan struct{}
|
||||
}
|
||||
|
||||
func (d *hangingDataStore) BackupTo(w io.Writer) error {
|
||||
<-d.block
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
@@ -128,6 +141,18 @@ func TestEncryptDecrypt_WrongPassword(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_backupDb_timesOutWhenBackupToHangs(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ds := &hangingDataStore{DataStore: testhelpers.NewDatastore(), block: make(chan struct{})}
|
||||
|
||||
err := backupDb(t.TempDir(), ds)
|
||||
require.Error(t, err)
|
||||
|
||||
close(ds.block)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateBackupArchive_NoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dataservices
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
)
|
||||
@@ -49,6 +51,7 @@ type (
|
||||
Rollback(force bool) error
|
||||
CheckCurrentEdition() error
|
||||
Backup(path string) (string, error)
|
||||
BackupTo(w io.Writer) error
|
||||
Export(filename string) (err error)
|
||||
|
||||
DataStoreTx
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
var windowsAbsolutePathPrefix = regexp.MustCompile(`^[A-Za-z]:[\\/]`)
|
||||
var windowsAbsolutePathPrefix = regexp.MustCompile(`^([A-Za-z]:[\\/]|\\\\)`)
|
||||
|
||||
type MountDescriptor struct {
|
||||
Type string
|
||||
@@ -19,7 +19,7 @@ type MountDescriptor struct {
|
||||
}
|
||||
|
||||
func IsBindMount(m MountDescriptor) bool {
|
||||
if strings.EqualFold(m.Type, "bind") {
|
||||
if strings.EqualFold(m.Type, "bind") || strings.EqualFold(m.Type, "npipe") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ func TestIsBindMount(t *testing.T) {
|
||||
|
||||
// tmpfs without a device is not a bind mount
|
||||
f(MountDescriptor{DriverOpts: map[string]string{"type": "tmpfs"}}, false)
|
||||
|
||||
// Windows named-pipe mount is bind-equivalent, case-insensitively
|
||||
f(MountDescriptor{Type: "npipe"}, true)
|
||||
f(MountDescriptor{Type: "NPipe"}, true)
|
||||
}
|
||||
|
||||
func TestIsBindPath(t *testing.T) {
|
||||
@@ -78,6 +82,12 @@ func TestIsBindPath(t *testing.T) {
|
||||
|
||||
// named volume, not a host path
|
||||
f("myvolume:/data", false)
|
||||
|
||||
// Windows named-pipe UNC path
|
||||
f(`\\.\pipe\docker_engine:\\.\pipe\docker_engine`, true)
|
||||
|
||||
// Windows network-share UNC path
|
||||
f(`\\fileserver\share:/data`, true)
|
||||
}
|
||||
|
||||
func newVolumeInspectClient(t *testing.T, handler http.HandlerFunc) *client.Client {
|
||||
|
||||
+46
-7
@@ -2,14 +2,37 @@ package filesystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const backupTimeoutEnvVar = "PORTAINER_BACKUP_TIMEOUT"
|
||||
|
||||
var BackupTimeout = resolveBackupTimeout(time.Hour)
|
||||
|
||||
func resolveBackupTimeout(defaultTimeout time.Duration) time.Duration {
|
||||
val := os.Getenv(backupTimeoutEnvVar)
|
||||
if val == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str(backupTimeoutEnvVar, val).Msg("failed to parse " + backupTimeoutEnvVar + " variable")
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
// CopyPath copies file or directory defined by the path to the toDir path
|
||||
func CopyPath(path string, toDir string) error {
|
||||
info, err := os.Stat(path)
|
||||
@@ -20,12 +43,28 @@ func CopyPath(path string, toDir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
destination := JoinPaths(toDir, info.Name())
|
||||
return copyFile(path, destination)
|
||||
}
|
||||
return RunWithTimeout(path, BackupTimeout, func() error {
|
||||
if !info.IsDir() {
|
||||
return copyFile(path, JoinPaths(toDir, info.Name()))
|
||||
}
|
||||
|
||||
return CopyDir(path, toDir, true)
|
||||
return CopyDir(path, toDir, true)
|
||||
})
|
||||
}
|
||||
|
||||
func RunWithTimeout(label string, timeout time.Duration, fn func() error) error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- fn()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(timeout):
|
||||
log.Error().Str("operation", label).Dur("timeout", timeout).Msg("timed out")
|
||||
return fmt.Errorf("timed out running %s after %s", label, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// CopyDir copies contents of fromDir to toDir.
|
||||
@@ -53,8 +92,8 @@ func CopyDir(fromDir, toDir string, keepParent bool) error {
|
||||
return nil // skip directory creations
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink
|
||||
return nil // don't copy symlinks
|
||||
if !info.Mode().IsRegular() { // skip symlinks, FIFOs, sockets, devices
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyFile(path, destination)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -92,6 +95,45 @@ func Test_CopyPath_shouldCopyDir(t *testing.T) {
|
||||
assert.FileExists(t, JoinPaths(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_RunWithTimeout_returnsUnderlyingResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, RunWithTimeout("op", time.Hour, func() error { return nil }))
|
||||
|
||||
boom := errors.New("boom")
|
||||
require.ErrorIs(t, RunWithTimeout("op", time.Hour, func() error { return boom }), boom)
|
||||
}
|
||||
|
||||
func Test_RunWithTimeout_timesOutWhenFnNeverReturnsInTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
|
||||
err := RunWithTimeout("op", time.Hour, func() error {
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
close(release)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_resolveBackupTimeout_returnsDefaultWhenUnset(t *testing.T) {
|
||||
t.Setenv(backupTimeoutEnvVar, "")
|
||||
assert.Equal(t, time.Hour, resolveBackupTimeout(time.Hour))
|
||||
}
|
||||
|
||||
func Test_resolveBackupTimeout_usesParsedValue(t *testing.T) {
|
||||
t.Setenv(backupTimeoutEnvVar, "30m")
|
||||
assert.Equal(t, 30*time.Minute, resolveBackupTimeout(time.Hour))
|
||||
}
|
||||
|
||||
func Test_resolveBackupTimeout_returnsDefaultWhenInvalid(t *testing.T) {
|
||||
t.Setenv(backupTimeoutEnvVar, "not-a-duration")
|
||||
assert.Equal(t, time.Hour, resolveBackupTimeout(time.Hour))
|
||||
}
|
||||
|
||||
func TestCopyPathPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build unix
|
||||
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_CopyDir_shouldSkipNonRegularFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
|
||||
err := os.WriteFile(JoinPaths(source, "regular"), []byte("content"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = syscall.Mkfifo(JoinPaths(source, "fifo"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = CopyDir(source, destination, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, JoinPaths(destination, "regular"))
|
||||
assert.NoFileExists(t, JoinPaths(destination, "fifo"))
|
||||
}
|
||||
@@ -10,6 +10,8 @@ type (
|
||||
K8sConfiguration
|
||||
}
|
||||
|
||||
// K8sSecret carries base64 encoded Data values, unlike K8sConfigMap whose values
|
||||
// are plain text: a secret holds arbitrary bytes that a JSON string cannot represent.
|
||||
K8sSecret struct {
|
||||
K8sConfiguration
|
||||
SecretType string `json:"SecretType"`
|
||||
|
||||
@@ -34,7 +34,7 @@ type partialServiceSpec struct {
|
||||
} `json:"TaskTemplate"`
|
||||
}
|
||||
|
||||
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings, getClient func() (*client.Client, error)) error {
|
||||
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings, getClient func(nodeName string) (*client.Client, error)) error {
|
||||
defer logs.CloseAndLogErr(request.Body)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
@@ -83,21 +83,13 @@ func CheckServiceBodyRestrictions(request *http.Request, securitySettings *porta
|
||||
}
|
||||
|
||||
if len(referencedVolumes) > 0 {
|
||||
cli, err := getClient()
|
||||
isBind, err := anyClusterNodeHasBindMountVolume(request.Context(), getClient, referencedVolumes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer logs.CloseAndLogErr(cli)
|
||||
|
||||
for _, name := range referencedVolumes {
|
||||
isBind, err := docker.InspectVolumeIsBindMount(request.Context(), cli, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isBind {
|
||||
return ErrBindMountsForbidden
|
||||
}
|
||||
if isBind {
|
||||
return ErrBindMountsForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +99,45 @@ func CheckServiceBodyRestrictions(request *http.Request, securitySettings *porta
|
||||
return nil
|
||||
}
|
||||
|
||||
func anyClusterNodeHasBindMountVolume(ctx context.Context, getClient func(nodeName string) (*client.Client, error), volumeNames []string) (bool, error) {
|
||||
cli, err := getClient("")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer logs.CloseAndLogErr(cli)
|
||||
|
||||
nodes, err := cli.NodeList(ctx, swarm.NodeListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
nodeClients := make([]*client.Client, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
nodeClient, err := getClient(node.Description.Hostname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
defer logs.CloseAndLogErr(nodeClient)
|
||||
nodeClients = append(nodeClients, nodeClient)
|
||||
}
|
||||
|
||||
for _, name := range volumeNames {
|
||||
for _, nodeClient := range nodeClients {
|
||||
isBind, err := docker.InspectVolumeIsBindMount(ctx, nodeClient, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isBind {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
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{})
|
||||
if err != nil {
|
||||
@@ -193,10 +224,8 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
getClient := func() (*client.Client, error) {
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
return transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
|
||||
getClient := func(nodeName string) (*client.Client, error) {
|
||||
return transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
|
||||
}
|
||||
|
||||
if err := CheckServiceBodyRestrictions(request, securitySettings, getClient); err != nil {
|
||||
@@ -228,10 +257,8 @@ func (transport *Transport) decorateServiceUpdateOperation(request *http.Request
|
||||
return nil, err
|
||||
}
|
||||
|
||||
getClient := func() (*client.Client, error) {
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
return transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
|
||||
getClient := func(nodeName string) (*client.Client, error) {
|
||||
return transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
|
||||
}
|
||||
|
||||
if err := CheckServiceBodyRestrictions(request, securitySettings, getClient); err != nil {
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -47,6 +49,20 @@ func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && path.Base(r.URL.Path) == "nodes" {
|
||||
data, err := json.Marshal([]swarm.Node{{ID: "single-node", Description: swarm.NodeDescription{Hostname: "single-node"}}})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
if vol, ok := f.volumes[path.Base(r.URL.Path)]; ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -727,3 +743,85 @@ func TestDecorateServiceCreationOperation_BindMountRestrictions(t *testing.T) {
|
||||
"normalvol": {Name: "normalvol", Driver: "local"},
|
||||
}, false)
|
||||
}
|
||||
|
||||
// TestCheckServiceBodyRestrictions_BindVolumeOnOtherClusterNode ensures a bind-backed volume is
|
||||
// caught even when it only exists on a cluster node other than the one the default client reaches.
|
||||
// A local-driver volume is scoped to the node it was created on: checking a single node cannot
|
||||
// rule out a same-named bind-backed volume on a different node that the Swarm scheduler could
|
||||
// still place the task on.
|
||||
func TestCheckServiceBodyRestrictions_BindVolumeOnOtherClusterNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newNodeServer := func(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
nodeAServer := newNodeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if path.Base(r.URL.Path) == "nodes" {
|
||||
data, err := json.Marshal([]swarm.Node{
|
||||
{ID: "node-a", Description: swarm.NodeDescription{Hostname: "node-a"}},
|
||||
{ID: "node-b", Description: swarm.NodeDescription{Hostname: "node-b"}},
|
||||
})
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
nodeBServer := newNodeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if path.Base(r.URL.Path) == "evilvol" {
|
||||
vol := volume.Volume{Name: "evilvol", Driver: "local", Options: map[string]string{"type": "none", "o": "bind", "device": "/etc"}}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
assert.NoError(t, json.NewEncoder(w).Encode(vol))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
newClientFor := func(url string) *client.Client {
|
||||
cli, err := client.NewClientWithOpts(client.WithHost(url), client.WithHTTPClient(http.DefaultClient))
|
||||
require.NoError(t, err)
|
||||
|
||||
return cli
|
||||
}
|
||||
|
||||
getClient := func(nodeName string) (*client.Client, error) {
|
||||
if nodeName == "node-b" {
|
||||
return newClientFor(nodeBServer.URL), nil
|
||||
}
|
||||
|
||||
return newClientFor(nodeAServer.URL), nil
|
||||
}
|
||||
|
||||
spec := swarm.ServiceSpec{
|
||||
TaskTemplate: swarm.TaskSpec{
|
||||
ContainerSpec: &swarm.ContainerSpec{
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "evilvol"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(spec)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://unused/services/create", bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = CheckServiceBodyRestrictions(req, &restrictiveSettings, getClient)
|
||||
require.ErrorIs(t, err, ErrBindMountsForbidden)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package testhelpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -45,6 +46,7 @@ type testDatastore struct {
|
||||
}
|
||||
|
||||
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
|
||||
func (d *testDatastore) BackupTo(w io.Writer) error { return nil }
|
||||
func (d *testDatastore) Open() (bool, error) { return false, nil }
|
||||
func (d *testDatastore) Init() error { return nil }
|
||||
func (d *testDatastore) Close() error { return nil }
|
||||
|
||||
@@ -2,8 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
@@ -150,6 +152,20 @@ func (kcl *KubeClient) DeleteSecret(namespace, name string) error {
|
||||
return kcl.cli.CoreV1().Secrets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
|
||||
}
|
||||
|
||||
// secretAnnotations returns the secret's annotations without the one kubectl writes on
|
||||
// apply, whose value is the whole object including its data. Leaving it in would hand
|
||||
// the data to every caller withData is meant to withhold it from.
|
||||
func secretAnnotations(secret *corev1.Secret) map[string]string {
|
||||
if _, hasLastApplied := secret.Annotations[lastAppliedConfigAnnotation]; !hasLastApplied {
|
||||
return secret.Annotations
|
||||
}
|
||||
|
||||
annotations := maps.Clone(secret.Annotations)
|
||||
delete(annotations, lastAppliedConfigAnnotation)
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// parseSecret parses a k8s Secret object into a K8sSecret struct.
|
||||
// for get operation, withData will be set to true.
|
||||
// otherwise, only metadata will be parsed.
|
||||
@@ -160,7 +176,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
Name: secret.Name,
|
||||
Namespace: secret.Namespace,
|
||||
CreationDate: secret.CreationTimestamp.Time.UTC().Format(time.RFC3339),
|
||||
Annotations: secret.Annotations,
|
||||
Annotations: secretAnnotations(secret),
|
||||
Labels: secret.Labels,
|
||||
ConfigurationOwner: secret.Labels[labelPortainerKubeConfigOwner],
|
||||
ConfigurationOwnerId: secret.Labels[labelPortainerKubeConfigOwnerId],
|
||||
@@ -169,10 +185,11 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
}
|
||||
|
||||
if withData {
|
||||
secretData := secret.Data
|
||||
secretDataMap := make(map[string]string, len(secretData))
|
||||
for key, value := range secretData {
|
||||
secretDataMap[key] = string(value)
|
||||
secretDataMap := make(map[string]string, len(secret.Data))
|
||||
for key, value := range secret.Data {
|
||||
// a secret holds arbitrary bytes and a JSON string must be valid UTF-8, so
|
||||
// values go over the wire base64 encoded, as the Kubernetes API does
|
||||
secretDataMap[key] = base64.StdEncoding.EncodeToString(value)
|
||||
}
|
||||
|
||||
result.Data = secretDataMap
|
||||
|
||||
@@ -150,3 +150,44 @@ func Test_SetSecretsIsUsed_SAWithEmptyImagePullSecrets(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, secrets[0].IsUsed)
|
||||
}
|
||||
|
||||
// The kubectl apply annotation holds the whole object, data included, so leaving it in
|
||||
// a response would defeat the withData check that withholds secret values.
|
||||
func Test_parseSecret_StripsTheKubectlApplyAnnotation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "applied",
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
lastAppliedConfigAnnotation: `{"apiVersion":"v1","data":{"password":"c3VwZXJzZWNyZXQ="},"kind":"Secret"}`,
|
||||
"portainer.io/registry.id": "3",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{"password": []byte("supersecret")},
|
||||
}
|
||||
|
||||
for _, withData := range []bool{false, true} {
|
||||
result := parseSecret(secret, withData)
|
||||
|
||||
assert.NotContains(t, result.Annotations, lastAppliedConfigAnnotation)
|
||||
assert.Equal(t, "3", result.Annotations["portainer.io/registry.id"])
|
||||
}
|
||||
|
||||
assert.Contains(t, secret.Annotations, lastAppliedConfigAnnotation, "the live secret must not be mutated")
|
||||
}
|
||||
|
||||
func Test_parseSecret_KeepsAnnotationsWhenThereIsNothingToStrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "plain",
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{"portainer.io/registry.id": "3"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, secret.Annotations, parseSecret(secret, false).Annotations)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func IsValidStackFile(config StackFileValidationConfig) error {
|
||||
}
|
||||
|
||||
if config.DockerClient == nil {
|
||||
continue
|
||||
return fmt.Errorf("volume %q: unable to verify bind-mount status without a Docker client", volumeKey)
|
||||
}
|
||||
|
||||
isBind, err := docker.InspectVolumeIsBindMount(context.Background(), config.DockerClient, volumeConfig.Name)
|
||||
@@ -66,7 +66,7 @@ func IsValidStackFile(config StackFileValidationConfig) error {
|
||||
for _, service := range composeConfig.Services {
|
||||
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
|
||||
for _, volume := range service.Volumes {
|
||||
if strings.EqualFold(volume.Type, "bind") {
|
||||
if docker.IsBindMount(docker.MountDescriptor{Type: volume.Type}) {
|
||||
return errors.New("bind-mount disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +443,19 @@ services:
|
||||
target: /container/path
|
||||
`), "", nil, forbidden)
|
||||
|
||||
// service-level volume "type: npipe" (Windows named pipe) is bind-equivalent
|
||||
f([]byte(`
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: nginx
|
||||
volumes:
|
||||
- type: npipe
|
||||
source: \\.\pipe\docker_engine
|
||||
target: \\.\pipe\docker_engine
|
||||
`), "", nil, forbidden)
|
||||
|
||||
// an external volume that is actually bind-backed is rejected
|
||||
f([]byte(`
|
||||
version: "3"
|
||||
@@ -480,7 +493,7 @@ volumes:
|
||||
Options: map[string]string{"type": "none", "o": "bind", "device": "/etc"},
|
||||
}), forbidden)
|
||||
|
||||
// with no Docker client, the existing-volume check is skipped and the stack is valid
|
||||
// with no Docker client, the existing-volume check fails closed rather than being skipped
|
||||
f([]byte(`
|
||||
version: "3"
|
||||
|
||||
@@ -492,7 +505,7 @@ services:
|
||||
|
||||
volumes:
|
||||
data: {}
|
||||
`), "mystack", nil, "")
|
||||
`), "mystack", nil, "unable to verify bind-mount status without a Docker client")
|
||||
|
||||
// an explicit "name:" override resolves to the overridden Docker-side name
|
||||
f([]byte(`
|
||||
|
||||
@@ -242,7 +242,7 @@ angular
|
||||
|
||||
var group = {
|
||||
name: 'portainer.groups.group',
|
||||
url: '/:id',
|
||||
url: '/:id?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'environmentGroupEditView',
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HeaderContainer({ id, children }: PropsWithChildren<Props>) {
|
||||
<Context.Provider value>
|
||||
<div
|
||||
id={id}
|
||||
className="row !mb-[5px] min-h-[60px] !rounded-none !border-0 !border-b !border-solid !border-b-[var(--border-widget)] bg-[var(--bg-widget-color)] !shadow-none"
|
||||
className="row !mb-[15px] min-h-[60px] !rounded-none !border-0 !border-b !border-solid !border-b-[var(--border-widget)] bg-[var(--bg-widget-color)] !shadow-none"
|
||||
>
|
||||
<div id="loadingbar-placeholder" />
|
||||
<div className="col-xs-12">
|
||||
|
||||
+13
@@ -157,4 +157,17 @@ describe('AccessDatatable', () => {
|
||||
expect(screen.getByText(/logout and login/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdatingAccess prop', () => {
|
||||
it('should show "Removing..." text on remove button when isUpdatingAccess is true', () => {
|
||||
const mockAccess = createMockAccess();
|
||||
renderComponent({
|
||||
isLoading: false,
|
||||
isUpdatingAccess: true,
|
||||
dataset: [mockAccess],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Removing...')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-1
@@ -72,7 +72,11 @@ export function AccessDatatable({
|
||||
isRowSelectable={({ original: item }) => !inheritFrom || !item.Inherited}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<>
|
||||
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
|
||||
<RemoveAccessButton
|
||||
items={selectedItems}
|
||||
onClick={onRemove}
|
||||
isLoading={isUpdatingAccess}
|
||||
/>
|
||||
|
||||
{isBE && isUpdateEnabled && (
|
||||
<LoadingButton
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { OptionProps, components, MultiValueGenericProps } from 'react-select';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
type Option = { Type: 'user' | 'team'; Id: number; Name: string };
|
||||
export type Option = { Type: 'user' | 'team'; Id: number; Name: string };
|
||||
|
||||
interface Props {
|
||||
value: Option[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box } from 'lucide-react';
|
||||
import { Box, UsersRound } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
@@ -15,6 +15,7 @@ import { useGroup } from '../queries/useGroup';
|
||||
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
|
||||
|
||||
import { EnvironmentsTab } from './tabs/EnvironmentsTab';
|
||||
import { AccessTab } from './tabs/AccessTab';
|
||||
import { GroupHeader } from './GroupHeader';
|
||||
|
||||
export function EditGroupView() {
|
||||
@@ -60,6 +61,12 @@ export function EditGroupView() {
|
||||
),
|
||||
selectedTabParam: 'environments',
|
||||
},
|
||||
{
|
||||
name: 'Access',
|
||||
icon: UsersRound,
|
||||
widget: <AccessTab />,
|
||||
selectedTabParam: 'access',
|
||||
},
|
||||
],
|
||||
[addEnvsDrawerOpen]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||
|
||||
import { EnvironmentGroup } from '../../types';
|
||||
|
||||
import { AccessTab } from './AccessTab';
|
||||
|
||||
vi.mock('@/react/hooks/useIdParam', () => ({
|
||||
useIdParam: () => 2,
|
||||
}));
|
||||
|
||||
vi.mock('@/portainer/services/notifications', () => ({
|
||||
notifyError: vi.fn(),
|
||||
notifySuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGroup: EnvironmentGroup = {
|
||||
Id: 2,
|
||||
Name: 'Test Group',
|
||||
Description: '',
|
||||
TagIds: [],
|
||||
};
|
||||
|
||||
function setupMocks({
|
||||
group = mockGroup,
|
||||
users = [] as Array<{ Id: number; Username: string; Role: number }>,
|
||||
teams = [] as Array<{ Id: number; Name: string }>,
|
||||
onUpdate = vi.fn(),
|
||||
holdUpdate,
|
||||
}: {
|
||||
group?: EnvironmentGroup;
|
||||
users?: Array<{ Id: number; Username: string; Role: number }>;
|
||||
teams?: Array<{ Id: number; Name: string }>;
|
||||
onUpdate?: (body: unknown) => void;
|
||||
holdUpdate?: Promise<void>;
|
||||
} = {}) {
|
||||
server.use(
|
||||
http.get('/api/endpoint_groups/2', () => HttpResponse.json(group)),
|
||||
http.get('/api/users', () => HttpResponse.json(users)),
|
||||
http.get('/api/teams', () => HttpResponse.json(teams)),
|
||||
http.put('/api/endpoint_groups/2', async ({ request }) => {
|
||||
onUpdate(await request.json());
|
||||
await holdUpdate;
|
||||
return HttpResponse.json(group);
|
||||
})
|
||||
);
|
||||
|
||||
return onUpdate;
|
||||
}
|
||||
|
||||
function renderAccessTab() {
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withTestRouter(withUserProvider(AccessTab))
|
||||
);
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('AccessTab', () => {
|
||||
test('shows the authorized users and teams, and hides the rest', async () => {
|
||||
setupMocks({
|
||||
group: {
|
||||
...mockGroup,
|
||||
UserAccessPolicies: { 5: { RoleId: 3 } },
|
||||
TeamAccessPolicies: { 3: { RoleId: 3 } },
|
||||
},
|
||||
users: [
|
||||
{ Id: 5, Username: 'authorized-user', Role: 2 },
|
||||
{ Id: 6, Username: 'other-user', Role: 2 },
|
||||
],
|
||||
teams: [
|
||||
{ Id: 3, Name: 'authorized-team' },
|
||||
{ Id: 4, Name: 'other-team' },
|
||||
],
|
||||
});
|
||||
renderAccessTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('authorized-user')).toBeVisible();
|
||||
});
|
||||
expect(screen.getByText('authorized-team')).toBeVisible();
|
||||
expect(screen.queryByText('other-user')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('other-team')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('assigns a selected user to the group', async () => {
|
||||
const onUpdate = setupMocks({
|
||||
group: { ...mockGroup, TeamAccessPolicies: { 3: { RoleId: 3 } } },
|
||||
users: [{ Id: 6, Username: 'new-user', Role: 2 }],
|
||||
teams: [{ Id: 3, Name: 'authorized-team' }],
|
||||
});
|
||||
renderAccessTab();
|
||||
|
||||
const selector = await screen.findByLabelText(
|
||||
'Select user(s) and/or team(s)'
|
||||
);
|
||||
await userEvent.click(selector);
|
||||
await userEvent.click(await screen.findByText('new-user'));
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /Create access/ })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
UserAccessPolicies: { 6: { RoleId: 3 } },
|
||||
TeamAccessPolicies: { 3: { RoleId: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
// the selector only clears once the mutation succeeds
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove new-user' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('creating access leaves the remove button idle', async () => {
|
||||
// never resolves, so the create request stays in flight while we assert
|
||||
setupMocks({
|
||||
group: { ...mockGroup, UserAccessPolicies: { 5: { RoleId: 3 } } },
|
||||
users: [
|
||||
{ Id: 5, Username: 'authorized-user', Role: 2 },
|
||||
{ Id: 6, Username: 'new-user', Role: 2 },
|
||||
],
|
||||
holdUpdate: new Promise<void>(() => {}),
|
||||
});
|
||||
renderAccessTab();
|
||||
|
||||
const selector = await screen.findByLabelText(
|
||||
'Select user(s) and/or team(s)'
|
||||
);
|
||||
await userEvent.click(selector);
|
||||
await userEvent.click(await screen.findByText('new-user'));
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /Create access/ })
|
||||
);
|
||||
|
||||
await screen.findByText('Creating access...');
|
||||
expect(screen.queryByText('Removing...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the selection when creating access fails', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
setupMocks({ users: [{ Id: 6, Username: 'new-user', Role: 2 }] });
|
||||
server.use(
|
||||
http.put(
|
||||
'/api/endpoint_groups/2',
|
||||
() => new HttpResponse(null, { status: 500 })
|
||||
)
|
||||
);
|
||||
renderAccessTab();
|
||||
|
||||
const selector = await screen.findByLabelText(
|
||||
'Select user(s) and/or team(s)'
|
||||
);
|
||||
await userEvent.click(selector);
|
||||
await userEvent.click(await screen.findByText('new-user'));
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /Create access/ })
|
||||
);
|
||||
|
||||
// once the request settles the selection is still there to retry with
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Creating access...')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove new-user' })
|
||||
).toBeVisible();
|
||||
restoreConsole();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
PortainerTeamAccessPolicies,
|
||||
PortainerUserAccessPolicies,
|
||||
} from '@api/types.gen';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||
import { AccessDatatable } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable';
|
||||
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
|
||||
import { Option } from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector';
|
||||
|
||||
import { useGroup } from '../../queries/useGroup';
|
||||
import { useGroupAccesses } from '../../queries/useGroupAccesses';
|
||||
import { useUpdateGroupAccessMutation } from '../../queries/useUpdateGroupAccessMutation';
|
||||
|
||||
import { CreateAccessWidget } from './CreateAccessWidget';
|
||||
|
||||
export function AccessTab() {
|
||||
const groupId = useIdParam();
|
||||
const groupQuery = useGroup(groupId);
|
||||
const group = groupQuery.data;
|
||||
const { availableUsersAndTeams, authorizedUsersAndTeams, isLoading } =
|
||||
useGroupAccesses(group);
|
||||
const createMutation = useUpdateGroupAccessMutation();
|
||||
const datatableMutation = useUpdateGroupAccessMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="m-4">
|
||||
<CreateAccessWidget
|
||||
availableUsersAndTeams={availableUsersAndTeams as Array<Option>}
|
||||
isLoading={isLoading || groupQuery.isLoading}
|
||||
isUpdating={createMutation.isLoading}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
<AccessDatatable
|
||||
tableKey="access_group"
|
||||
dataset={authorizedUsersAndTeams}
|
||||
onRemove={handleRemove}
|
||||
onUpdate={handleUpdate}
|
||||
showWarning
|
||||
showRoles
|
||||
isUpdateEnabled
|
||||
isUpdatingAccess={datatableMutation.isLoading}
|
||||
isLoading={isLoading || groupQuery.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleCreate(
|
||||
usersAndTeams: Array<Option>,
|
||||
roleId: number,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
updatePolicies(
|
||||
createMutation,
|
||||
usersAndTeams.map((access) => ({ ...access, Role: { Id: roleId } })),
|
||||
'set',
|
||||
'Access successfully updated',
|
||||
onSuccess
|
||||
);
|
||||
}
|
||||
|
||||
function handleUpdate(
|
||||
updatedUsers: Array<Access>,
|
||||
updatedTeams: Array<Access>
|
||||
) {
|
||||
updatePolicies(
|
||||
datatableMutation,
|
||||
[...updatedUsers, ...updatedTeams],
|
||||
'set',
|
||||
'Access successfully updated'
|
||||
);
|
||||
}
|
||||
|
||||
function handleRemove(accesses: Array<Access>) {
|
||||
updatePolicies(
|
||||
datatableMutation,
|
||||
accesses,
|
||||
'delete',
|
||||
'Access successfully removed'
|
||||
);
|
||||
}
|
||||
|
||||
function updatePolicies(
|
||||
mutation: ReturnType<typeof useUpdateGroupAccessMutation>,
|
||||
accesses: Array<{ Id: number; Type: string; Role?: { Id: number } }>,
|
||||
action: 'set' | 'delete',
|
||||
successMessage: string,
|
||||
onSuccess?: () => void
|
||||
) {
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userAccessPolicies: PortainerUserAccessPolicies = {
|
||||
...group.UserAccessPolicies,
|
||||
};
|
||||
const teamAccessPolicies: PortainerTeamAccessPolicies = {
|
||||
...group.TeamAccessPolicies,
|
||||
};
|
||||
|
||||
accesses.forEach((access) => {
|
||||
const policies =
|
||||
access.Type === 'user' ? userAccessPolicies : teamAccessPolicies;
|
||||
|
||||
if (action === 'delete') {
|
||||
delete policies[access.Id];
|
||||
} else {
|
||||
policies[access.Id] = { RoleId: access.Role?.Id ?? 0 };
|
||||
}
|
||||
});
|
||||
|
||||
mutation.mutate(
|
||||
{ id: group.Id, userAccessPolicies, teamAccessPolicies },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', successMessage);
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { UserPlus, Plus } from 'lucide-react';
|
||||
|
||||
import { RoleTypes } from '@/portainer/rbac/models/role';
|
||||
import { RoleService } from '@/portainer/rbac/services/role.service';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import {
|
||||
Option,
|
||||
PorAccessManagementUsersSelector,
|
||||
} from '@/react/portainer/access-control/AccessManagement/PorAccessManagementUsersSelector';
|
||||
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
|
||||
|
||||
interface Props {
|
||||
availableUsersAndTeams: Array<Option>;
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
onSubmit(
|
||||
usersAndTeams: Array<Option>,
|
||||
roleId: number,
|
||||
onSuccess: () => void
|
||||
): void;
|
||||
}
|
||||
|
||||
export function CreateAccessWidget({
|
||||
availableUsersAndTeams,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const rolesLimitedToBE = isLimitedToBE(FeatureId.RBAC_ROLES);
|
||||
const [selectedUsersAndTeams, setSelectedUsersAndTeams] = useState<
|
||||
Array<Option>
|
||||
>([]);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number>(
|
||||
RoleTypes.STANDARD
|
||||
);
|
||||
|
||||
const roleOptions = RoleService()
|
||||
.roles()
|
||||
.map((role) => ({
|
||||
label: getRoleLabel(role.ID, role.Name),
|
||||
value: role.ID as number,
|
||||
disabled: isRoleLimited(role.ID),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Widget aria-label="Create access">
|
||||
<WidgetTitle icon={UserPlus} title="Create access" />
|
||||
<WidgetBody>
|
||||
<TextTip className="mb-4" childrenWrapperClassName="text-warning">
|
||||
Adding user access will require the affected user(s) to logout and
|
||||
login for the changes to be taken into account.
|
||||
</TextTip>
|
||||
|
||||
<form className="form-horizontal" onSubmit={handleSubmit}>
|
||||
<PorAccessManagementUsersSelector
|
||||
options={availableUsersAndTeams}
|
||||
value={selectedUsersAndTeams}
|
||||
onChange={(value) => setSelectedUsersAndTeams([...value])}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<FormControl label="Role" inputId="role-selector">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<PortainerSelect
|
||||
inputId="role-selector"
|
||||
value={selectedRoleId}
|
||||
onChange={(roleId) =>
|
||||
setSelectedRoleId(roleId ?? RoleTypes.STANDARD)
|
||||
}
|
||||
options={roleOptions}
|
||||
data-cy="access-management-role-select"
|
||||
/>
|
||||
</div>
|
||||
<BEFeatureIndicator
|
||||
featureId={FeatureId.RBAC_ROLES}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="!ml-0"
|
||||
disabled={selectedUsersAndTeams.length === 0}
|
||||
isLoading={isUpdating}
|
||||
loadingText="Creating access..."
|
||||
icon={Plus}
|
||||
data-cy="access-createAccess"
|
||||
>
|
||||
Create access
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
onSubmit(selectedUsersAndTeams, selectedRoleId, () =>
|
||||
setSelectedUsersAndTeams([])
|
||||
);
|
||||
}
|
||||
|
||||
function isRoleLimited(roleId: number) {
|
||||
return rolesLimitedToBE && roleId !== RoleTypes.STANDARD;
|
||||
}
|
||||
|
||||
function getRoleLabel(roleId: number, roleName: string) {
|
||||
if (!rolesLimitedToBE) {
|
||||
return roleName;
|
||||
}
|
||||
|
||||
return isRoleLimited(roleId)
|
||||
? `${roleName} (Business Feature)`
|
||||
: `${roleName} (Default)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import _ from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
TeamAccessViewModel,
|
||||
UserAccessViewModel,
|
||||
} from '@/portainer/models/access';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries/useTeams';
|
||||
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
|
||||
|
||||
import { EnvironmentGroup } from '../types';
|
||||
|
||||
/** Splits the users and teams between those already authorized on the group and those still available to be added. */
|
||||
export function useGroupAccesses(group?: EnvironmentGroup) {
|
||||
const usersQuery = useUsers(false, 0, !!group, (users) =>
|
||||
users.map((user) => new UserAccessViewModel(user))
|
||||
);
|
||||
const teamsQuery = useTeams(false, 0, {
|
||||
enabled: !!group,
|
||||
select: (teams) => teams.map((team) => new TeamAccessViewModel(team)),
|
||||
});
|
||||
|
||||
const userPolicies = group?.UserAccessPolicies;
|
||||
const teamPolicies = group?.TeamAccessPolicies;
|
||||
const users = usersQuery.data;
|
||||
const teams = teamsQuery.data;
|
||||
|
||||
const accesses = useMemo(() => {
|
||||
const [authorized, available] = _.partition(
|
||||
[...(users || []), ...(teams || [])] as Array<Access>,
|
||||
(access) =>
|
||||
access.Type === 'user'
|
||||
? !!userPolicies?.[access.Id]
|
||||
: !!teamPolicies?.[access.Id]
|
||||
);
|
||||
|
||||
return {
|
||||
authorizedUsersAndTeams: authorized,
|
||||
availableUsersAndTeams: _.orderBy(available, 'Name', 'asc'),
|
||||
};
|
||||
}, [users, teams, userPolicies, teamPolicies]);
|
||||
|
||||
return {
|
||||
...accesses,
|
||||
isLoading: usersQuery.isLoading || teamsQuery.isLoading,
|
||||
};
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
PortainerTeamAccessPolicies,
|
||||
PortainerUserAccessPolicies,
|
||||
} from '@api/types.gen';
|
||||
import { endpointGroupUpdate } from '@api/sdk.gen';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EnvironmentGroupId } from '../../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
interface UpdateGroupAccessPayload {
|
||||
id: EnvironmentGroupId;
|
||||
userAccessPolicies: PortainerUserAccessPolicies;
|
||||
teamAccessPolicies: PortainerTeamAccessPolicies;
|
||||
}
|
||||
|
||||
async function updateGroupAccess({
|
||||
id,
|
||||
userAccessPolicies,
|
||||
teamAccessPolicies,
|
||||
}: UpdateGroupAccessPayload) {
|
||||
await endpointGroupUpdate({
|
||||
path: { id },
|
||||
body: {
|
||||
UserAccessPolicies: userAccessPolicies,
|
||||
TeamAccessPolicies: teamAccessPolicies,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroupAccessMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateGroupAccess,
|
||||
onSuccess: () => queryClient.invalidateQueries(queryKeys.base()),
|
||||
...withError('Unable to update group access'),
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type GetOptions struct {
|
||||
ShowResources bool
|
||||
Revision int
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
ReleaseStorage ReleaseStorage
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ type HistoryOptions struct {
|
||||
Name string
|
||||
Namespace string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
ReleaseStorage ReleaseStorage
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type ListOptions struct {
|
||||
Selector string
|
||||
Namespace string
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
ReleaseStorage ReleaseStorage
|
||||
|
||||
Env []string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
|
||||
// ReleaseStorage supplies the Kubernetes Secret client backing the Helm release
|
||||
// storage driver. It is offered only on the read options, because release records
|
||||
// are Portainer's own bookkeeping rather than a resource the caller asked for: a
|
||||
// caller with no Kubernetes access to Secrets can still be told which releases
|
||||
// exist. Everything else the action touches keeps using KubernetesClusterAccess,
|
||||
// so live cluster reads and every write still obey Kubernetes RBAC.
|
||||
//
|
||||
// Nil, the default, means "use KubernetesClusterAccess" and is what Community
|
||||
// Edition passes. Whoever supplies a client takes on scoping the results and
|
||||
// withholding release content, since the storage driver enforces neither.
|
||||
//
|
||||
// A kubernetes.Interface satisfies this through its CoreV1() method.
|
||||
type ReleaseStorage = corev1.SecretsGetter
|
||||
@@ -54,6 +54,29 @@ type Release struct {
|
||||
Values Values `json:"values,omitzero"`
|
||||
}
|
||||
|
||||
// RedactSensitive removes everything about a release that is rendered from the chart
|
||||
// and can therefore carry Kubernetes Secret data: the manifest, the hooks, both value
|
||||
// sets and the rendered notes. A rendered chart routinely contains Secret objects with
|
||||
// real values, and values files routinely contain credentials.
|
||||
//
|
||||
// Info.Resources survives. It is reduced to each object's metadata, kind and status
|
||||
// before it reaches here, so it identifies what the release owns without disclosing
|
||||
// anything the resource APIs would not.
|
||||
func (r *Release) RedactSensitive() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.Manifest = ""
|
||||
r.Hooks = nil
|
||||
r.Config = nil
|
||||
r.Values = Values{}
|
||||
|
||||
if r.Info != nil {
|
||||
r.Info.Notes = ""
|
||||
}
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
|
||||
ComputedValues string `json:"computedValues,omitempty"`
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// clearedByRedaction and keptByRedaction together must name every field of Release.
|
||||
// A new field is a deliberate choice between the two, and TestRedactSensitive_CoversEveryField
|
||||
// fails until that choice is made.
|
||||
var (
|
||||
clearedByRedaction = []string{"Manifest", "Hooks", "Config", "Values"}
|
||||
keptByRedaction = []string{"Name", "Info", "Chart", "AppVersion", "Version", "Namespace", "Labels", "ChartReference", "StackID"}
|
||||
)
|
||||
|
||||
func populatedRelease() *Release {
|
||||
return &Release{
|
||||
Name: "demo",
|
||||
Namespace: "portainer",
|
||||
Version: 3,
|
||||
AppVersion: "1.2.3",
|
||||
Manifest: "apiVersion: v1\nkind: Secret\ndata:\n password: c3VwZXJzZWNyZXQ=\n",
|
||||
Config: map[string]any{"password": "supersecret"},
|
||||
Hooks: []*Hook{{Name: "pre-install", Manifest: "kind: Secret"}},
|
||||
Values: Values{
|
||||
UserSuppliedValues: "password: supersecret",
|
||||
ComputedValues: "password: supersecret",
|
||||
},
|
||||
Labels: map[string]string{"owner": "helm"},
|
||||
ChartReference: ChartReference{RepoURL: "https://charts.example.com"},
|
||||
StackID: 7,
|
||||
Chart: Chart{Values: map[string]any{"password": "default"}},
|
||||
Info: &Info{
|
||||
Status: "deployed",
|
||||
Description: "Install complete",
|
||||
FirstDeployed: time.Now(),
|
||||
LastDeployed: time.Now(),
|
||||
Notes: "Your password is supersecret",
|
||||
Resources: []*unstructured.Unstructured{{Object: map[string]any{"kind": "Deployment"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := populatedRelease()
|
||||
r.RedactSensitive()
|
||||
|
||||
t.Run("clears everything that can carry secret data", func(t *testing.T) {
|
||||
assert.Empty(t, r.Manifest, "the rendered manifest can contain Secret objects")
|
||||
assert.Empty(t, r.Hooks, "hook manifests can contain Secret objects")
|
||||
assert.Empty(t, r.Config, "extra values can contain credentials")
|
||||
assert.Equal(t, Values{}, r.Values, "user-supplied and computed values can contain credentials")
|
||||
assert.Empty(t, r.Info.Notes, "notes are templated and can interpolate values")
|
||||
})
|
||||
|
||||
t.Run("keeps the resource list, which carries no secret data", func(t *testing.T) {
|
||||
assert.Len(t, r.Info.Resources, 1, "resources are reduced to metadata, kind and status before they reach redaction")
|
||||
})
|
||||
|
||||
t.Run("keeps the metadata needed to identify the release", func(t *testing.T) {
|
||||
assert.Equal(t, "demo", r.Name)
|
||||
assert.Equal(t, "portainer", r.Namespace)
|
||||
assert.Equal(t, 3, r.Version)
|
||||
assert.Equal(t, "1.2.3", r.AppVersion)
|
||||
assert.Equal(t, 7, r.StackID)
|
||||
assert.Equal(t, "https://charts.example.com", r.ChartReference.RepoURL)
|
||||
assert.EqualValues(t, "deployed", r.Info.Status)
|
||||
assert.Equal(t, "Install complete", r.Info.Description)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestRedactSensitive_NilInfoAndNilReceiver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.NotPanics(t, func() { (*Release)(nil).RedactSensitive() })
|
||||
|
||||
r := &Release{Name: "demo"}
|
||||
assert.NotPanics(t, func() { r.RedactSensitive() })
|
||||
}
|
||||
|
||||
// TestRedactSensitive_CoversEveryField fails when a field is added to Release without
|
||||
// deciding whether redaction should clear it. Without this, a new field carrying secret
|
||||
// data would ship through the redacted response unnoticed.
|
||||
func TestRedactSensitive_CoversEveryField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
classified := make(map[string]bool, len(clearedByRedaction)+len(keptByRedaction))
|
||||
for _, name := range append(append([]string{}, clearedByRedaction...), keptByRedaction...) {
|
||||
classified[name] = true
|
||||
}
|
||||
|
||||
releaseType := reflect.TypeFor[Release]()
|
||||
for field := range releaseType.Fields() {
|
||||
assert.True(t, classified[field.Name], "Release.%s is neither cleared nor kept by RedactSensitive; add it to clearedByRedaction or keptByRedaction", field.Name)
|
||||
}
|
||||
|
||||
assert.Len(t, classified, releaseType.NumField(), "clearedByRedaction/keptByRedaction name a field that no longer exists on Release")
|
||||
}
|
||||
|
||||
// TestRedactSensitive_ClearsEveryFieldItClaimsTo guards the other direction: a field listed
|
||||
// as cleared must actually come back zero.
|
||||
func TestRedactSensitive_ClearsEveryFieldItClaimsTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := populatedRelease()
|
||||
before := reflect.ValueOf(*r)
|
||||
for _, name := range clearedByRedaction {
|
||||
require.False(t, before.FieldByName(name).IsZero(), "the fixture must populate %s for this test to mean anything", name)
|
||||
}
|
||||
|
||||
r.RedactSensitive()
|
||||
|
||||
after := reflect.ValueOf(*r)
|
||||
for _, name := range clearedByRedaction {
|
||||
assert.True(t, after.FieldByName(name).IsZero(), "RedactSensitive left Release.%s populated", name)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
slogzerolog "github.com/samber/slog-zerolog/v2"
|
||||
"helm.sh/helm/v4/pkg/action"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/storage"
|
||||
"helm.sh/helm/v4/pkg/storage/driver"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/discovery/cached/memory"
|
||||
@@ -38,7 +40,11 @@ func namespaceOrDefault(namespace string) string {
|
||||
// storage cluster-wide, which is how `helm list --all-namespaces` sees
|
||||
// releases in every namespace (action.List.AllNamespaces alone does not widen
|
||||
// the storage scope).
|
||||
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
|
||||
//
|
||||
// releaseStorage, when non-nil, backs the release storage driver instead of the
|
||||
// client derived from k8sAccess. Only the read paths offer it; see
|
||||
// options.ReleaseStorage.
|
||||
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess, releaseStorage options.ReleaseStorage) error {
|
||||
// Setup logging for Helm SDK using zerolog
|
||||
logger := log.With().Str("context", "HelmClient").Logger()
|
||||
logOptions := slogzerolog.Option{
|
||||
@@ -50,7 +56,11 @@ func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configu
|
||||
// Use default kubeconfig
|
||||
settings := cli.New()
|
||||
clientGetter := settings.RESTClientGetter()
|
||||
return actionConfig.Init(clientGetter, namespace, "secret")
|
||||
if err := actionConfig.Init(clientGetter, namespace, "secret"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return useReleaseStorage(actionConfig, namespace, releaseStorage)
|
||||
}
|
||||
|
||||
// Create client config
|
||||
@@ -70,7 +80,27 @@ func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configu
|
||||
return err
|
||||
}
|
||||
|
||||
return actionConfig.Init(clientGetter, namespace, "secret")
|
||||
if err := actionConfig.Init(clientGetter, namespace, "secret"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return useReleaseStorage(actionConfig, namespace, releaseStorage)
|
||||
}
|
||||
|
||||
// useReleaseStorage repoints the release storage driver at the supplied client,
|
||||
// leaving actionConfig.KubeClient on the caller's own credentials so live cluster
|
||||
// access still goes through Kubernetes RBAC. A nil client leaves the storage as
|
||||
// actionConfig.Init built it.
|
||||
func useReleaseStorage(actionConfig *action.Configuration, namespace string, releaseStorage options.ReleaseStorage) error {
|
||||
if releaseStorage == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
secretsDriver := driver.NewSecrets(releaseStorage.Secrets(namespace))
|
||||
secretsDriver.SetLogger(actionConfig.Logger().Handler())
|
||||
actionConfig.Releases = storage.Init(secretsDriver)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateConfigAPI generates a new kubeconfig configuration
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
@@ -8,6 +9,11 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"helm.sh/helm/v4/pkg/action"
|
||||
sdkrelease "helm.sh/helm/v4/pkg/release"
|
||||
releasecommon "helm.sh/helm/v4/pkg/release/common"
|
||||
releasev1 "helm.sh/helm/v4/pkg/release/v1"
|
||||
"helm.sh/helm/v4/pkg/storage/driver"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/client-go/tools/clientcmd/api"
|
||||
)
|
||||
@@ -18,7 +24,7 @@ func Test_InitActionConfig(t *testing.T) {
|
||||
|
||||
t.Run("with nil k8sAccess should use default kubeconfig", func(t *testing.T) {
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, "default", nil)
|
||||
err := hspm.initActionConfig(actionConfig, "default", nil, nil)
|
||||
|
||||
// The function should not fail by design, even when not running in a k8s environment
|
||||
require.NoError(t, err, "should not return error when not in k8s environment")
|
||||
@@ -32,7 +38,7 @@ func Test_InitActionConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// The function should not fail by design
|
||||
err := hspm.initActionConfig(actionConfig, "default", k8sAccess)
|
||||
err := hspm.initActionConfig(actionConfig, "default", k8sAccess, nil)
|
||||
require.NoError(t, err, "should not return error when using in-memory config")
|
||||
})
|
||||
|
||||
@@ -45,9 +51,63 @@ func Test_InitActionConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// The function should not fail by design
|
||||
err := hspm.initActionConfig(actionConfig, "default", k8sAccess)
|
||||
err := hspm.initActionConfig(actionConfig, "default", k8sAccess, nil)
|
||||
require.NoError(t, err, "should not return error when using in-memory config with CA")
|
||||
})
|
||||
|
||||
t.Run("with release storage the driver reads through the supplied client", func(t *testing.T) {
|
||||
storageClient := fake.NewClientset()
|
||||
seedRelease(t, storageClient, "portainer", "demo")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
k8sAccess := &options.KubernetesClusterAccess{
|
||||
ClusterServerURL: "https://kubernetes.default.svc",
|
||||
AuthToken: "test-token",
|
||||
}
|
||||
require.NoError(t, hspm.initActionConfig(actionConfig, "portainer", k8sAccess, storageClient.CoreV1()))
|
||||
|
||||
releases, err := actionConfig.Releases.List(func(sdkrelease.Releaser) bool { return true })
|
||||
require.NoError(t, err, "the storage driver should read through the supplied client")
|
||||
require.Len(t, releases, 1)
|
||||
|
||||
found, err := releaserToV1Release(releases[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "demo", found.Name)
|
||||
})
|
||||
|
||||
t.Run("without release storage the driver cannot see the supplied client's releases", func(t *testing.T) {
|
||||
storageClient := fake.NewClientset()
|
||||
seedRelease(t, storageClient, "portainer", "demo")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
k8sAccess := &options.KubernetesClusterAccess{
|
||||
ClusterServerURL: "https://kubernetes.default.svc",
|
||||
AuthToken: "test-token",
|
||||
}
|
||||
require.NoError(t, hspm.initActionConfig(actionConfig, "portainer", k8sAccess, nil))
|
||||
|
||||
// The caller's credentials point at an unreachable cluster, so this either errors
|
||||
// or comes back empty. What matters is that the seeded release is not visible.
|
||||
releases, err := actionConfig.Releases.List(func(sdkrelease.Releaser) bool { return true })
|
||||
if err == nil {
|
||||
assert.Empty(t, releases)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedRelease writes a Helm release record through the secret driver itself, so the
|
||||
// stored encoding is whatever the driver expects to read back.
|
||||
func seedRelease(t *testing.T, client *fake.Clientset, namespace, name string) {
|
||||
t.Helper()
|
||||
|
||||
seeded := driver.NewSecrets(client.CoreV1().Secrets(namespace))
|
||||
err := seeded.Create(fmt.Sprintf("sh.helm.release.v1.%s.v1", name), &releasev1.Release{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Version: 1,
|
||||
Info: &releasev1.Info{Status: releasecommon.StatusDeployed},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_ClientConfigGetter(t *testing.T) {
|
||||
|
||||
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.
|
||||
Msg("Get Helm release")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOptions.Namespace), getOptions.KubernetesClusterAccess, getOptions.ReleaseStorage)
|
||||
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
||||
@@ -20,7 +20,7 @@ func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOpti
|
||||
Msg("Get Helm history")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(historyOptions.Namespace), historyOptions.KubernetesClusterAccess, historyOptions.ReleaseStorage)
|
||||
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
||||
@@ -37,7 +37,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
||||
|
||||
// Initialize action configuration with kubernetes config
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(installOpts.Namespace), installOpts.KubernetesClusterAccess, nil)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
|
||||
|
||||
@@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release
|
||||
// cluster-wide so the list covers every namespace, mirroring how the Helm
|
||||
// CLI implements `helm list --all-namespaces`.
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess, listOpts.ReleaseStorage)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return nil, errors.Wrap(err, "failed to initialize helm configuration")
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
|
||||
// Initialize action configuration
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(namespace), clusterAccess, nil)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
||||
|
||||
@@ -30,7 +30,7 @@ func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions
|
||||
|
||||
// Initialize action configuration with kubernetes config
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(rollbackOpts.Namespace), rollbackOpts.KubernetesClusterAccess, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
|
||||
|
||||
// Initialize action configuration with kubernetes config
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess, nil)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return errors.Wrap(err, "failed to initialize helm configuration")
|
||||
@@ -103,7 +103,7 @@ func (hspm *HelmSDKPackageManager) ForceRemoveRelease(uninstallOpts options.Unin
|
||||
Msg("Force-removing release history (skipping resource deletion)")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(uninstallOpts.Namespace), uninstallOpts.KubernetesClusterAccess, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
||||
|
||||
// Initialize action configuration with kubernetes config
|
||||
actionConfig := new(action.Configuration)
|
||||
err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess)
|
||||
err = hspm.initActionConfig(actionConfig, namespaceOrDefault(upgradeOpts.Namespace), upgradeOpts.KubernetesClusterAccess, nil)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
|
||||
|
||||
@@ -84,7 +84,7 @@ func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (releas
|
||||
Msg("Getting values")
|
||||
|
||||
actionConfig := new(action.Configuration)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess)
|
||||
err := hspm.initActionConfig(actionConfig, namespaceOrDefault(getOpts.Namespace), getOpts.KubernetesClusterAccess, getOpts.ReleaseStorage)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
||||
@@ -50,10 +50,25 @@ func newMockReleaseElement(installOpts options.InstallOptions) *release.ReleaseE
|
||||
}
|
||||
}
|
||||
|
||||
// MockReleaseSecretValue appears in every content field of a mock release, so a test can
|
||||
// assert that redaction left none of it in a response.
|
||||
const MockReleaseSecretValue = "supersecret"
|
||||
|
||||
func newMockRelease(re *release.ReleaseElement) *release.Release {
|
||||
return &release.Release{
|
||||
Name: re.Name,
|
||||
Namespace: re.Namespace,
|
||||
Manifest: "apiVersion: v1\nkind: Secret\ndata:\n password: " + MockReleaseSecretValue + "\n",
|
||||
Config: map[string]any{"password": MockReleaseSecretValue},
|
||||
Hooks: []*release.Hook{{Name: "pre-install", Manifest: "password: " + MockReleaseSecretValue}},
|
||||
Values: release.Values{
|
||||
UserSuppliedValues: "password: " + MockReleaseSecretValue,
|
||||
ComputedValues: "password: " + MockReleaseSecretValue,
|
||||
},
|
||||
Info: &release.Info{
|
||||
Status: release.Status(re.Status),
|
||||
Notes: "Your password is " + MockReleaseSecretValue,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user