Compare commits

..

2 Commits

101 changed files with 433 additions and 2556 deletions
-1
View File
@@ -95,7 +95,6 @@ body:
multiple: false
options:
- '2.34.0'
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
+10
View File
@@ -1,3 +1,9 @@
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
# For a list of valid GOOS and GOARCH values
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
PLATFORM=$(shell go env GOOS)
ARCH=$(shell go env GOARCH)
# build target, can be one of "production", "testing", "development"
ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
@@ -31,6 +37,10 @@ build-image: build-all ## Build the Portainer image locally
build-storybook: ## Build and serve the storybook files
yarn storybook:build
devops: clean deps build-client ## Build the everything target specifically for CI
echo "Building the devops binary..."
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
##@ Build dependencies
.PHONY: deps server-deps client-deps tidy
deps: server-deps client-deps ## Download all client and server build dependancies
-1
View File
@@ -56,7 +56,6 @@ func CLIFlags() *portainer.CLIFlags {
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ func initFileService(dataStorePath string) portainer.FileService {
}
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
if err != nil {
log.Fatal().Err(err).Msg("failed creating database connection")
}
+8 -68
View File
@@ -21,9 +21,6 @@ import (
const (
DatabaseFileName = "portainer.db"
EncryptedDatabaseFileName = "portainer.edb"
txMaxSize = 65536
compactedSuffix = ".compacted"
)
var (
@@ -38,7 +35,6 @@ type DbConnection struct {
InitialMmapSize int
EncryptionKey []byte
isEncrypted bool
Compact bool
*bolt.DB
}
@@ -136,8 +132,15 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
func (connection *DbConnection) Open() error {
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
// Now we open the db
databasePath := connection.GetDatabaseFilePath()
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
})
if err != nil {
return err
}
@@ -146,24 +149,6 @@ func (connection *DbConnection) Open() error {
db.MaxBatchDelay = connection.MaxBatchDelay
connection.DB = db
if connection.Compact {
log.Info().Msg("compacting database")
if err := connection.compact(); err != nil {
log.Error().Err(err).Msg("failed to compact database")
// Close the read-only database and re-open in read-write mode
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
}
connection.Compact = false
return connection.Open()
} else {
log.Info().Msg("database compaction completed")
}
}
return nil
}
@@ -429,48 +414,3 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
return err
}
// compact attempts to compact the database and replace it iff it succeeds
func (connection *DbConnection) compact() (err error) {
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
}
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
if err != nil {
return fmt.Errorf("failure to create the compacted database: %w", err)
}
compactedDB.MaxBatchSize = connection.MaxBatchSize
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
return fmt.Errorf("failure to compact the database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
return fmt.Errorf("failure to move the compacted database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after compaction")
}
connection.DB = compactedDB
return nil
}
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
return &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
ReadOnly: readOnly,
}
}
-60
View File
@@ -5,11 +5,7 @@ import (
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
)
func Test_NeedsEncryptionMigration(t *testing.T) {
@@ -123,59 +119,3 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
})
}
}
func TestDBCompaction(t *testing.T) {
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
require.NoError(t, err)
err = db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
if err != nil {
return err
}
b.Put([]byte("key"), []byte("value"))
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Reopen the DB to trigger compaction
db.Compact = true
err = db.Open()
require.NoError(t, err)
// Check that the data is still there
err = db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("testbucket"))
if b == nil {
return nil
}
val := b.Get([]byte("key"))
require.Equal(t, []byte("value"), val)
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Failures
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
err = os.Mkdir(compactedPath, 0o755)
require.NoError(t, err)
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
require.NoError(t, err)
require.NoError(t, f.Close())
err = db.Open()
require.NoError(t, err)
}
+1 -2
View File
@@ -8,12 +8,11 @@ import (
)
// NewDatabase should use config options to return a connection to the requested database
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
if storeType == "boltdb" {
return &boltdb.DbConnection{
Path: storePath,
EncryptionKey: encryptionKey,
Compact: compact,
}, nil
}
-24
View File
@@ -1,24 +0,0 @@
package database
import (
"testing"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/require"
)
func TestNewDatabase(t *testing.T) {
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
connection, err := NewDatabase("boltdb", dbPath, nil, false)
require.NoError(t, err)
require.NotNil(t, connection)
_, ok := connection.(*boltdb.DbConnection)
require.True(t, ok)
connection, err = NewDatabase("unknown", dbPath, nil, false)
require.Error(t, err)
require.Nil(t, connection)
}
@@ -614,7 +614,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.35.0",
"KubectlShellImage": "portainer/kubectl-shell:2.34.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.35.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.34.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -44,7 +44,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
secretKey = nil
}
connection, err := database.NewDatabase("boltdb", storePath, secretKey, false)
connection, err := database.NewDatabase("boltdb", storePath, secretKey)
if err != nil {
panic(err)
}
-5
View File
@@ -49,11 +49,6 @@ type (
// Is relative path supported
SupportRelativePath bool
// AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent must always clone the git repository for relative path.
// This field is only valid when SupportRelativePath is true.
// Used only for EE
AlwaysCloneGitRepoForRelativePath bool
// Mount point for relative path
FilesystemPath string
// Used only for EE
+49 -56
View File
@@ -256,7 +256,7 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil
}
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, statusFilter portainer.EdgeStackStatusType) bool {
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
// consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending {
return stackStatus == nil || len(stackStatus.Status) == 0
@@ -272,62 +272,55 @@ func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusFo
}
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
var filteredEndpoints []portainer.Endpoint
if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
stack, err := tx.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupId := range stack.EdgeGroups {
edgeGroup, err := tx.EdgeGroup().Read(edgeGroupId)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve edge group from the database")
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
filteredEnvIds := roar.Roar[portainer.EndpointID]{}
filteredEnvIds.Union(envIds)
if statusFilter != nil {
var innerErr error
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := tx.EdgeStackStatus().Read(edgeStackId, envId)
if err != nil && !dataservices.IsErrObjectNotFound(err) {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, *statusFilter) {
filteredEnvIds.Remove(envId)
}
return true
})
if innerErr != nil {
return innerErr
}
}
filteredEndpoints = filteredEndpointsByIds(endpoints, filteredEnvIds)
return nil
}); err != nil {
return nil, err
stack, err := datastore.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupdId := range stack.EdgeGroups {
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge group from the database")
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(datastore, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
if statusFilter != nil {
var innerErr error
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if dataservices.IsErrObjectNotFound(err) {
return true
} else if err != nil {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) {
envIds.Remove(envId)
}
return true
})
if innerErr != nil {
return nil, innerErr
}
}
filteredEndpoints := filteredEndpointsByIds(endpoints, envIds)
return filteredEndpoints, nil
}
+26 -88
View File
@@ -5,7 +5,6 @@ import (
"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/portainer/portainer/api/internal/testhelpers"
@@ -298,103 +297,42 @@ func TestFilterEndpointsByEdgeStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1", Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 2, Name: "Endpoint 2", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 3, Name: "Endpoint 3", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
}
edgeStackId := portainer.EdgeStackID(1)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Tag().Create(&portainer.Tag{ID: 1, Name: "tag", Endpoints: map[portainer.EndpointID]bool{2: true, 3: true}}))
for i := range endpoints {
require.NoError(t, tx.Endpoint().Create(&endpoints[i]))
}
require.NoError(t, tx.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
Dynamic: true,
TagIDs: []portainer.TagID{1},
}))
require.NoError(t, tx.EdgeStackStatus().Create(edgeStackId, endpoints[0].ID, &portainer.EdgeStackStatusForEnv{
Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusAcknowledged}}}))
return nil
}))
test := func(status *portainer.EdgeStackStatusType, expected []portainer.Endpoint) {
tmp := make([]portainer.Endpoint, len(endpoints))
require.Equal(t, 4, copy(tmp, endpoints))
es, err := filterEndpointsByEdgeStack(tmp, edgeStackId, status, store)
require.NoError(t, err)
// validate that the len is the same
require.Len(t, es, len(expected))
// and that all items are the expected ones
for i := range expected {
require.Contains(t, es, expected[i])
}
}
test(nil, []portainer.Endpoint{endpoints[0], endpoints[1], endpoints[2]})
status := portainer.EdgeStackStatusPending
test(&status, []portainer.Endpoint{endpoints[1], endpoints[2]})
status = portainer.EdgeStackStatusCompleted
test(&status, []portainer.Endpoint{})
status = portainer.EdgeStackStatusAcknowledged
test(&status, []portainer.Endpoint{endpoints[0]}) // that's the only one with an edge stack status in DB
}
func TestErrorsFilterEndpointsByEdgeStack(t *testing.T) {
t.Run("must error by edge stack not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
})
require.NoError(t, err)
t.Run("must error by edge group not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
t.Run("must error by env tag not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "edge group", Dynamic: true, TagIDs: []portainer.TagID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
})
require.NoError(t, err)
es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store)
require.NoError(t, err)
require.Len(t, es, 3)
require.Contains(t, es, endpoints[0]) // Endpoint 1
require.Contains(t, es, endpoints[1]) // Endpoint 2
require.Contains(t, es, endpoints[2]) // Endpoint 3
require.NotContains(t, es, endpoints[3]) // Endpoint 4
}
func TestFilterEndpointsByEdgeGroup(t *testing.T) {
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.35.0
// @version 2.34.0
// @description.markdown api-description.md
// @termsOfService
@@ -73,14 +73,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg))
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API environment(endpoint)
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
@@ -1,78 +0,0 @@
package stacks
import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gofrs/uuid"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
webhook, err := uuid.NewV4()
require.NoError(t, err)
_, store := datastore.MustNewTestStore(t, false, false)
endpoint := &portainer.Endpoint{
ID: 123,
Name: "endpoint1",
Type: portainer.DockerEnvironment,
}
err = store.Endpoint().Create(endpoint)
require.NoError(t, err)
stack1 := portainer.Stack{
ID: 456,
EndpointID: endpoint.ID,
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/portainer/portainer.git",
},
}
err = store.Stack().Create(&stack1)
require.NoError(t, err)
stack2 := stack1
stack2.ID++
stack2.AutoUpdate = nil
err = store.Stack().Create(&stack2)
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
payload := &stackGitUpdatePayload{
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID))
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload))
rrc := &security.RestrictedRequestContext{}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusConflict, rr.Code)
}
-5
View File
@@ -23,11 +23,6 @@ var allowedHeaders = map[string]struct{}{
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
"X-Stream-Protocol-Version": {},
// WebSocket headers those are required for kubectl exec/attach/port-forward operations
"Sec-Websocket-Key": {},
"Sec-Websocket-Version": {},
"Sec-Websocket-Protocol": {},
"Sec-Websocket-Extensions": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
+1 -1
View File
@@ -535,7 +535,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' https://cdn.matomo.cloud https://js.hsforms.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
+1 -1
View File
@@ -113,7 +113,7 @@ type datastoreOption = func(d *testDatastore)
// NewDatastore creates new instance of testDatastore.
// Will apply options before returning, opts will be applied from left to right.
func NewDatastore(options ...datastoreOption) *testDatastore {
conn, _ := database.NewDatabase("boltdb", "", nil, false)
conn, _ := database.NewDatabase("boltdb", "", nil)
d := testDatastore{connection: conn}
for _, o := range options {
+8 -20
View File
@@ -145,33 +145,21 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
}
// GetIsKubeAdmin retrieves true if client is admin
func (kcl *KubeClient) GetIsKubeAdmin() bool {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.isKubeAdmin
func (client *KubeClient) GetIsKubeAdmin() bool {
return client.IsKubeAdmin
}
// UpdateIsKubeAdmin sets whether the kube client is admin
func (kcl *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.isKubeAdmin = isKubeAdmin
func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
client.IsKubeAdmin = isKubeAdmin
}
// GetClientNonAdminNamespaces retrieves non-admin namespaces
func (kcl *KubeClient) GetClientNonAdminNamespaces() []string {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.nonAdminNamespaces
func (client *KubeClient) GetClientNonAdminNamespaces() []string {
return client.NonAdminNamespaces
}
// UpdateClientNonAdminNamespaces sets the client non admin namespace list
func (kcl *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.nonAdminNamespaces = nonAdminNamespaces
func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
client.NonAdminNamespaces = nonAdminNamespaces
}
-24
View File
@@ -67,27 +67,3 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf
})
}
}
func TestKubeAdmin(t *testing.T) {
kcl := &KubeClient{}
require.False(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(true)
require.True(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(false)
require.False(t, kcl.GetIsKubeAdmin())
}
func TestClientNonAdminNamespaces(t *testing.T) {
kcl := &KubeClient{}
require.Empty(t, kcl.GetClientNonAdminNamespaces())
nss := []string{"ns1", "ns2"}
kcl.SetClientNonAdminNamespaces(nss)
require.Equal(t, nss, kcl.GetClientNonAdminNamespaces())
kcl.SetClientNonAdminNamespaces([]string{})
require.Empty(t, kcl.GetClientNonAdminNamespaces())
}
+3 -7
View File
@@ -28,7 +28,7 @@ type PortainerApplicationResources struct {
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchApplications(namespace, nodeName)
}
@@ -64,13 +64,9 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching applications for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
+4 -4
View File
@@ -312,7 +312,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: true,
IsKubeAdmin: true,
}
// Test cases
@@ -387,8 +387,8 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: false,
nonAdminNamespaces: []string{namespace1},
IsKubeAdmin: false,
NonAdminNamespaces: []string{namespace1},
}
// Test that only resources from allowed namespace are returned
@@ -447,7 +447,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: true,
IsKubeAdmin: true,
}
// Test filtering by node name
+5 -7
View File
@@ -42,8 +42,8 @@ type (
cli kubernetes.Interface
instanceID string
mu sync.Mutex
isKubeAdmin bool
nonAdminNamespaces []string
IsKubeAdmin bool
NonAdminNamespaces []string
}
)
@@ -147,7 +147,6 @@ func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*Ku
if ok {
return client.(*KubeClient), true
}
return nil, false
}
@@ -180,8 +179,8 @@ func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, k
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
isKubeAdmin: IsKubeAdmin,
nonAdminNamespaces: NonAdminNamespaces,
IsKubeAdmin: IsKubeAdmin,
NonAdminNamespaces: NonAdminNamespaces,
}, nil
}
@@ -194,7 +193,7 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
isKubeAdmin: true,
IsKubeAdmin: true,
}, nil
}
@@ -372,7 +371,6 @@ func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, da
log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID)
return err
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRole objects.
func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchClusterRoles()
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoleBindings gets all the clusterRoleBindings for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRoleBinding objects.
func (kcl *KubeClient) GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchClusterRoleBindings()
}
+3 -8
View File
@@ -16,23 +16,18 @@ import (
// if the user is an admin, all configMaps in the current k8s environment(endpoint) are fetched using the fetchConfigMaps function.
// otherwise, namespaces the non-admin user has access to will be used to filter the configMaps based on the allowed namespaces.
func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchConfigMaps(namespace)
}
return kcl.fetchConfigMapsForNonAdmin(namespace)
}
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching configMaps for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// If the user is a kube admin, it returns all cronjobs in the namespace
// Otherwise, it returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchCronJobs(namespace)
}
+3 -3
View File
@@ -18,7 +18,7 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.isKubeAdmin = true
kcl.IsKubeAdmin = true
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
@@ -31,8 +31,8 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.isKubeAdmin = false
kcl.SetClientNonAdminNamespaces([]string{"default"})
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
+2 -2
View File
@@ -12,7 +12,7 @@ import (
// If the user is a kube admin, it returns all events in the namespace
// Otherwise, it returns only the events in the non-admin namespaces
func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchAllEvents(namespace, resourceId)
}
@@ -22,7 +22,7 @@ func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns only the events in the non-admin namespaces
func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) {
if len(kcl.GetClientNonAdminNamespaces()) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
+5 -5
View File
@@ -19,7 +19,7 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
isKubeAdmin: true,
IsKubeAdmin: true,
}
event := corev1.Event{
@@ -47,8 +47,8 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
isKubeAdmin: false,
nonAdminNamespaces: []string{"nonAdmin"},
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
@@ -77,8 +77,8 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
isKubeAdmin: false,
nonAdminNamespaces: []string{"nonAdmin"},
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
+3 -21
View File
@@ -12,16 +12,6 @@ import (
utilexec "k8s.io/client-go/util/exec"
)
var (
channelProtocolList = []string{
"v5.channel.k8s.io",
"v4.channel.k8s.io",
"v3.channel.k8s.io",
"v2.channel.k8s.io",
"channel.k8s.io",
}
)
// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
// to the stdout parameter.
@@ -55,18 +45,10 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
return
}
errChan <- err
return
}
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
+3 -8
View File
@@ -87,22 +87,17 @@ func (kcl *KubeClient) GetIngress(namespace, ingressName string) (models.K8sIngr
// GetIngresses gets all the ingresses for a given namespace in a k8s endpoint.
func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchIngresses(namespace)
}
return kcl.fetchIngressesForNonAdmin(namespace)
}
// fetchIngressesForNonAdmin gets all the ingresses for non-admin users in a k8s endpoint.
func (kcl *KubeClient) fetchIngressesForNonAdmin(namespace string) ([]models.K8sIngressInfo, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching ingresses for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching ingresses for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
-15
View File
@@ -1,15 +0,0 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetIngresses(t *testing.T) {
kcl := &KubeClient{}
ingresses, err := kcl.GetIngresses("default")
require.NoError(t, err)
require.Empty(t, ingresses)
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// If the user is a kube admin, it returns all jobs in the namespace
// Otherwise, it returns only the jobs in the non-admin namespaces
func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchJobs(namespace, includeCronJobChildren)
}
+3 -3
View File
@@ -21,7 +21,7 @@ func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.isKubeAdmin = true
kcl.IsKubeAdmin = true
jobs, err := kcl.GetJobs("", false)
if err != nil {
@@ -34,8 +34,8 @@ func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.isKubeAdmin = false
kcl.SetClientNonAdminNamespaces([]string{"default"})
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
jobs, err := kcl.GetJobs("", false)
if err != nil {
+5 -9
View File
@@ -40,10 +40,9 @@ func defaultSystemNamespaces() map[string]struct{} {
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchNamespaces function.
// otherwise, namespaces the non-admin user has access to will be used to filter the namespaces based on the allowed namespaces.
func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchNamespaces()
}
return kcl.fetchNamespacesForNonAdmin()
}
@@ -53,7 +52,7 @@ func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNam
Str("context", "fetchNamespacesForNonAdmin").
Msg("Fetching namespaces for non-admin user")
if len(kcl.GetClientNonAdminNamespaces()) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
@@ -143,7 +142,6 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
Str("context", "CreateNamespace").
Str("Namespace", info.Name).
Msg("Failed to create the namespace")
return nil, err
}
@@ -159,7 +157,7 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
return namespace, nil
}
// UpdateNamespace updates a namespace in a k8s endpoint.
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
portainerLabels := map[string]string{
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
@@ -422,10 +420,8 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
// buildNonAdminNamespacesMap builds a map of non-admin namespaces.
// the map is used to filter the namespaces based on the allowed namespaces.
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
nonAdminNamespaceSet := make(map[string]struct{}, len(nonAdminNamespaces))
for _, namespace := range nonAdminNamespaces {
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
for _, namespace := range kcl.NonAdminNamespaces {
if !isSystemDefaultNamespace(namespace) {
nonAdminNamespaceSet[namespace] = struct{}{}
}
-1
View File
@@ -178,7 +178,6 @@ func Test_ToggleSystemState(t *testing.T) {
expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
}
actualPolicies, err := kcl.GetNamespaceAccessPolicies()
require.NoError(t, err, "failed to fetch policies")
assert.Equal(t, expectedPolicies, actualPolicies)
+4 -4
View File
@@ -46,9 +46,9 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace)
// if skipNamespace is set to "" then all namespaces are considered
func (kcl *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
limits := portainer.K8sNodeLimits{}
nodes, err := kcl.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
nodes, err := client.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
@@ -62,7 +62,7 @@ func (kcl *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnab
limits.Memory = memory / 1000000 // B to MB
if !overCommitEnabled {
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
namespaces, err := client.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
@@ -77,7 +77,7 @@ func (kcl *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnab
}
// minus accumulated resourcequotas for all namespaces except the one we're editing
resourceQuota, err := kcl.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
resourceQuota, err := client.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
if err != nil {
log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err)
continue // skip it
-1
View File
@@ -59,7 +59,6 @@ func Test_waitForPodStatus(t *testing.T) {
ctx, cancelFunc := context.WithTimeout(context.TODO(), 0*time.Second)
defer cancelFunc()
err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err)
+3 -8
View File
@@ -15,23 +15,18 @@ import (
// if the user is an admin, all resource quotas in all namespaces are fetched.
// otherwise, namespaces the non-admin user has access to will be used to filter the resource quotas.
func (kcl *KubeClient) GetResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchResourceQuotas(namespace)
}
return kcl.fetchResourceQuotasForNonAdmin(namespace)
}
// fetchResourceQuotasForNonAdmin gets the resource quotas in the current k8s environment(endpoint) for a non-admin user.
// the role of the user must have read access to the resource quotas in the defined namespaces.
func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]corev1.ResourceQuota, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching resource quotas for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching resource quotas for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
-15
View File
@@ -1,15 +0,0 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetResourceQuotas(t *testing.T) {
kcl := &KubeClient{}
resourceQuotas, err := kcl.GetResourceQuotas("default")
require.NoError(t, err)
require.Empty(t, resourceQuotas)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sRole objects.
func (kcl *KubeClient) GetRoles(namespace string) ([]models.K8sRole, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchRoles(namespace)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sRoleBinding objects.
func (kcl *KubeClient) GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchRoleBindings(namespace)
}
+3 -8
View File
@@ -23,23 +23,18 @@ const (
// if the user is an admin, all secrets in the current k8s environment(endpoint) are fetched using the getSecrets function.
// otherwise, namespaces the non-admin user has access to will be used to filter the secrets based on the allowed namespaces.
func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.getSecrets(namespace)
}
return kcl.getSecretsForNonAdmin(namespace)
}
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching secrets for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
+3 -8
View File
@@ -15,10 +15,9 @@ import (
// GetServices gets all the services for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sServiceInfo objects.
func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchServices(namespace)
}
return kcl.fetchServicesForNonAdmin(namespace)
}
@@ -26,13 +25,9 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e
// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces.
// it returns a list of K8sServiceInfo objects.
func (kcl *KubeClient) fetchServicesForNonAdmin(namespace string) ([]models.K8sServiceInfo, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching services for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching services for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetServiceAccounts gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sServiceAccount objects.
func (kcl *KubeClient) GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchServiceAccounts(namespace)
}
-15
View File
@@ -1,15 +0,0 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetServices(t *testing.T) {
kcl := &KubeClient{}
services, err := kcl.GetServices("default")
require.NoError(t, err)
require.Empty(t, services)
}
+3 -8
View File
@@ -18,10 +18,9 @@ import (
// If the user is not an admin, it fetches the volumes in the namespaces the user has access to.
// It returns a list of K8sVolumeInfo.
func (kcl *KubeClient) GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchVolumes(namespace)
}
return kcl.fetchVolumesForNonAdmin(namespace)
}
@@ -49,13 +48,9 @@ func (kcl *KubeClient) GetVolume(namespace, volumeName string) (*models.K8sVolum
// This function is called when the user is not an admin.
// It fetches all the persistent volume claims, persistent volumes and storage classes in the namespaces the user has access to.
func (kcl *KubeClient) fetchVolumesForNonAdmin(namespace string) ([]models.K8sVolumeInfo, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching volumes for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
-15
View File
@@ -1,15 +0,0 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetVolumes(t *testing.T) {
kcl := &KubeClient{}
volumes, err := kcl.GetVolumes("default")
require.NoError(t, err)
require.Empty(t, volumes)
}
+1 -6
View File
@@ -112,7 +112,6 @@ type (
AdminPasswordFile *string
Assets *string
CSP *bool
CompactDB *bool
Data *string
FeatureFlags *[]string
EnableEdgeComputeFeatures *bool
@@ -1112,8 +1111,6 @@ type (
StackOption struct {
// Prune services that are no longer referenced
Prune bool `example:"false"`
// Enable atomic rollback on failure (Helm --atomic flag for Kubernetes Helm stacks)
HelmAtomic bool `example:"false"`
}
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
@@ -1782,7 +1779,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.35.0"
APIVersion = "2.34.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
@@ -1847,8 +1844,6 @@ const (
TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
// CSPEnvVar is the environment variable used to enable/disable the Content Security Policy
CSPEnvVar = "CSP"
// CompactDBEnvVar is the environment variable used to enable/disable the startup compaction of the database
CompactDBEnvVar = "COMPACT_DB"
)
// List of supported features
+4 -21
View File
@@ -470,23 +470,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
},
};
const helmInstall = {
name: 'kubernetes.helminstall',
url: '/helm?referrer',
views: {
'content@': {
component: 'helmInstallView',
},
},
params: {
yaml: '',
},
data: {
docs: '/user/kubernetes/applications/manifest',
},
};
const namespaces = {
const resourcePools = {
name: 'kubernetes.resourcePools',
url: '/namespaces',
views: {
@@ -512,7 +496,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
},
};
const namespace = {
const resourcePool = {
name: 'kubernetes.resourcePools.resourcePool',
url: '/:id?tab',
views: {
@@ -691,13 +675,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(cluster);
$stateRegistryProvider.register(dashboard);
$stateRegistryProvider.register(deploy);
$stateRegistryProvider.register(helmInstall);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeStats);
$stateRegistryProvider.register(kubectlShell);
$stateRegistryProvider.register(namespaces);
$stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(namespaceCreation);
$stateRegistryProvider.register(namespace);
$stateRegistryProvider.register(resourcePool);
$stateRegistryProvider.register(namespaceAccess);
$stateRegistryProvider.register(volumes);
$stateRegistryProvider.register(volume);
-5
View File
@@ -24,7 +24,6 @@ import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
import { HelmInstallView } from '@/react/kubernetes/helm/install/HelmInstallView';
import { NodeView } from '@/react/kubernetes/cluster/NodeView/NodeView';
import { KubectlShellView } from '@/react/kubernetes/cluster/KubectlShell/KubectlShellView';
@@ -87,10 +86,6 @@ export const viewsModule = angular
'kubernetesHelmApplicationView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
)
.component(
'helmInstallView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmInstallView))), [])
)
.component(
'kubectlShellView',
r2a(withUIRouter(withReactQuery(withCurrentUser(KubectlShellView))), [])
+36 -9
View File
@@ -25,7 +25,7 @@
<!-- namespace -->
<div class="col-sm-12 form-section-title !mt-4"> Deploy to </div>
<div class="form-group" ng-if="ctrl.formValues.Namespace">
<div class="form-group" ng-if="ctrl.formValues.Namespace && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<label for="toggle_logo" class="col-lg-2 col-sm-3 control-label text-left">
Use namespace(s) specified from manifest
<portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'">
@@ -43,13 +43,15 @@
<label for="target_namespace" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-9 col-lg-10">
<namespace-portainer-select
ng-if="!ctrl.formValues.namespace_toggle"
is-disabled="ctrl.formValues.namespace_toggle || ctrl.state.isNamespaceInputDisabled"
ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
is-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM || ctrl.state.isNamespaceInputDisabled"
value="ctrl.formValues.Namespace"
on-change="(ctrl.onChangeNamespace)"
options="ctrl.namespaceOptions"
></namespace-portainer-select>
<span ng-if="ctrl.formValues.namespace_toggle" class="small text-muted pt-[7px]">Namespaces specified in the manifest will be used</span>
<span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]"
>Namespaces specified in the manifest will be used</span
>
</div>
</div>
@@ -61,11 +63,30 @@
</div>
<div class="form-group">
<label for="name" class="col-lg-2 col-sm-3 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10 text-muted small pt-[7px]"> Resource names specified in the manifest will be used </div>
<label for="name" class="col-lg-2 col-sm-3 control-label text-left" ng-class="{ required: ctrl.state.BuildMethod === ctrl.BuildMethods.HELM }">Name</label>
<div class="col-sm-9 col-lg-10 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
Resource names specified in the manifest will be used
</div>
<div class="col-sm-9 col-lg-10" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<input
type="text"
data-cy="name-input"
class="form-control"
ng-model="ctrl.formValues.Name"
id="name"
name="name"
placeholder="e.g. my-app"
required="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
/>
<div class="small text-warning mt-2">
<div ng-messages="ctrl.deploymentForm.name.$error">
<p ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p>
</div>
</div>
</div>
</div>
<div ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<div ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="mb-4 w-fit">
<stack-name-label-insight></stack-name-label-insight>
</div>
@@ -165,9 +186,15 @@
</div>
<!-- !url -->
<!-- Helm -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" />
</div>
<!-- !Helm -->
<!-- actions -->
<div class="col-sm-12 form-section-title !mt-4"> Actions </div>
<div class="form-group">
<div class="col-sm-12 form-section-title !mt-4" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"> Actions </div>
<div class="form-group" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="col-sm-12">
<button
type="button"
@@ -11,7 +11,7 @@ import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhoo
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
class KubernetesDeployController {
@@ -34,6 +34,7 @@ class KubernetesDeployController {
this.methodOptions = [
{ ...git, value: KubernetesDeployBuildMethods.GIT },
{ ...helm, value: KubernetesDeployBuildMethods.HELM },
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
{ ...url, value: KubernetesDeployBuildMethods.URL },
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
@@ -1,174 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Plus, Edit, Download, Settings } from 'lucide-react';
import { MenuButton, MenuButtonLink, MenuButtonProps } from './MenuButton';
const meta: Meta<PropsWithChildren<MenuButtonProps>> = {
component: MenuButton,
title: 'Components/Buttons/MenuButton',
render: (args) => <MenuButton {...args}>{args.children}</MenuButton>,
argTypes: {
color: {
control: 'select',
options: ['primary', 'secondary', 'danger', 'default', 'light'],
},
size: {
control: 'select',
options: ['xsmall', 'small', 'medium', 'large'],
},
dropdownPosition: {
control: 'select',
options: ['left', 'right'],
},
disabled: {
control: 'boolean',
},
// Hide props that don't make sense to control
items: { table: { disable: true } },
menuClassName: { table: { disable: true } },
className: { table: { disable: true } },
'data-cy': { table: { disable: true } },
icon: { table: { disable: true } },
title: { table: { disable: true } },
},
};
export default meta;
function basicItems() {
return [
<>
<MenuButtonLink data-cy="test" key="create" to="create">
<div className="flex items-center gap-2">
<Plus />
Create new
</div>
</MenuButtonLink>
<MenuButtonLink data-cy="test" key="edit" to="edit">
<div className="flex items-center gap-2">
<Edit />
Edit existing
</div>
</MenuButtonLink>
<MenuButtonLink data-cy="test" key="download" to="download">
<div className="flex items-center gap-2">
<Download />
Download
</div>
</MenuButtonLink>
</>,
];
}
type Story = StoryObj<PropsWithChildren<MenuButtonProps>>;
export const Primary: Story = {
args: {
items: basicItems(),
children: 'Actions',
color: 'primary',
size: 'small',
},
};
export const WithIcon: Story = {
args: {
items: basicItems(),
children: 'Settings',
color: 'primary',
icon: Settings,
'data-cy': 'menu-button-with-icon',
},
};
export const Large: Story = {
args: {
items: basicItems(),
children: 'Large Menu Button',
color: 'primary',
size: 'large',
icon: Settings,
'data-cy': 'menu-button-large',
},
};
export const Small: Story = {
args: {
items: basicItems(),
children: 'Small',
color: 'primary',
size: 'small',
'data-cy': 'menu-button-small',
},
};
export const XSmall: Story = {
args: {
items: basicItems(),
children: 'XS',
color: 'primary',
size: 'xsmall',
'data-cy': 'menu-button-xsmall',
},
};
export const DropdownRight: Story = {
args: {
items: basicItems(),
children: 'Right Aligned',
color: 'primary',
dropdownPosition: 'right',
'data-cy': 'menu-button-right',
},
decorators: [
(Story) => (
<div className="flex justify-end">
<Story />
</div>
),
],
};
export const Disabled: Story = {
args: {
items: basicItems(),
children: 'Disabled Menu',
color: 'primary',
disabled: true,
'data-cy': 'menu-button-disabled',
},
};
export const WithLinks: Story = {
args: {
items: [
<MenuButtonLink
key="external"
to="portainer.home"
label="External link"
data-cy="menu-button-link-external"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<Download />
External link
</div>
</MenuButtonLink>,
],
children: 'Mixed Actions',
color: 'primary',
'data-cy': 'menu-button-links',
},
};
export const CustomStyling: Story = {
args: {
items: basicItems(),
children: 'Custom Styled',
color: 'primary',
className: 'border-2 border-blue-5',
menuClassName: 'border-2 border-green-5',
'data-cy': 'menu-button-custom',
},
};
@@ -1,469 +0,0 @@
import {
ComponentType,
ReactNode,
PropsWithChildren,
useState,
useEffect,
useRef,
useContext,
createContext,
} from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Plus, Edit } from 'lucide-react';
import { MenuItem } from '@reach/menu-button';
import { UIRouter } from '@uirouter/react';
import { MenuButton, MenuButtonLink, MenuButtonProps } from './MenuButton';
type MockCommonProps = Record<string, unknown>;
type MockWithChildren = { children?: ReactNode };
type MockMenuButtonProps = MockWithChildren & {
as?: ComponentType<MockCommonProps>;
} & MockCommonProps;
type MockMenuItemProps = MockWithChildren & {
onSelect?: () => void;
disabled?: boolean;
} & MockCommonProps;
type MockMenuLinkProps = MockWithChildren & {
onClick?: () => void;
href?: string;
className?: string;
} & MockCommonProps;
type MockMenuProps = MockWithChildren;
type MockMenuFns = {
Menu: (props: MockMenuProps) => ReactNode;
MenuButton: (props: MockMenuButtonProps) => ReactNode;
MenuPopover: (props: MockWithChildren) => ReactNode;
MenuItem: (props: MockMenuItemProps) => ReactNode;
MenuLink: (props: MockMenuLinkProps) => ReactNode;
MenuList: (props: MockWithChildren) => ReactNode;
};
const mockUseSref = vi.hoisted(() => vi.fn());
vi.mock('@uirouter/react', () => ({
UIRouter: ({ children }: MockWithChildren) => children,
useSref: mockUseSref,
}));
vi.mock('@reach/menu-button', () => {
type Ctx = {
isOpen: boolean;
setOpen: (v: boolean) => void;
menuRef: React.RefObject<HTMLDivElement>;
};
const MenuCtx = createContext<Ctx | null>(null);
function Menu({ children }: MockWithChildren) {
const [isOpen, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleDocDown(e: MouseEvent) {
const target = e.target as Node | null;
if (
isOpen &&
menuRef.current &&
target &&
!menuRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleDocDown);
return () => document.removeEventListener('mousedown', handleDocDown);
}, [isOpen]);
return (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef }}>
<div data-cy="menu" ref={menuRef}>
{children}
</div>
</MenuCtx.Provider>
);
}
function MenuButton({
children,
as: Component,
...props
}: MockMenuButtonProps) {
const ctx = useContext(MenuCtx);
function onClick() {
ctx?.setOpen(!ctx.isOpen);
}
if (Component) {
return (
<Component data-cy="menu-button" onClick={onClick} {...props}>
{children}
</Component>
);
}
return (
<button data-cy="menu-button" type="button" onClick={onClick} {...props}>
{children}
</button>
);
}
function MenuPopover({ children }: MockWithChildren) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return <div data-cy="menu-popover">{children}</div>;
}
function MenuItem({
children,
onSelect,
disabled,
...props
}: MockMenuItemProps) {
const ctx = useContext(MenuCtx);
function handleClick() {
if (!disabled) {
onSelect?.();
ctx?.setOpen(false);
}
}
return (
<button
data-cy="menu-item"
role="menuitem"
type="button"
onClick={handleClick}
disabled={Boolean(disabled)}
style={{ opacity: disabled ? 0.5 : 1 }}
{...props}
>
{children}
</button>
);
}
function MenuLink({
children,
onClick,
href,
className,
...props
}: MockMenuLinkProps) {
const ctx = useContext(MenuCtx);
function handleClick() {
onClick?.();
ctx?.setOpen(false);
}
return (
<a
data-cy="menu-item"
role="menuitem"
href={href || '#'}
className={className}
onClick={(e) => {
e.preventDefault();
handleClick();
}}
{...props}
>
{children}
</a>
);
}
function MenuList({ children }: MockWithChildren) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return <div data-cy="menu-list">{children}</div>;
}
const exported: MockMenuFns = {
Menu,
MenuButton,
MenuPopover,
MenuItem,
MenuLink,
MenuList,
};
return exported;
});
function mapItems(items: Array<MockMenuButtonItem>) {
return items.map((item) => item.element);
}
type MockMenuButtonItem = {
id: string;
element: ReactNode;
handler?: () => void;
};
function createMockMenuItem({
id,
label,
icon,
onClick,
disabled,
}: {
id: string;
label: string;
icon?: ComponentType<unknown>;
onClick?: () => void;
disabled?: boolean;
}): MockMenuButtonItem {
const IconComponent = icon;
const handler = onClick;
return {
id,
handler,
element: (
<MenuItem key={id} disabled={disabled} onSelect={handler ?? (() => {})}>
{IconComponent ? <IconComponent /> : null}
{label}
</MenuItem>
),
};
}
const mockItems: Array<MockMenuButtonItem> = [
createMockMenuItem({
id: 'create',
label: 'Create new',
icon: Plus,
onClick: vi.fn(),
}),
createMockMenuItem({
id: 'edit',
label: 'Edit existing',
icon: Edit,
onClick: vi.fn(),
}),
];
type RenderOptions = Omit<
Partial<PropsWithChildren<MenuButtonProps>>,
'items'
> & {
items?: Array<MockMenuButtonItem>;
};
function renderDefault({
items = mockItems,
children = 'Test Menu',
color = 'primary',
size = 'small',
disabled = false,
...props
}: RenderOptions = {}) {
return render(
<MenuButton
items={mapItems(items)}
color={color}
size={size}
disabled={disabled}
data-cy="menu-button"
{...props}
>
{children}
</MenuButton>
);
}
beforeEach(() => {
vi.clearAllMocks();
// Set default mock implementation
mockUseSref.mockReturnValue({
href: '#default',
onClick: vi.fn(),
});
});
test('should display MenuButton with correct text and chevron icon', async () => {
const children = 'Test Actions';
renderDefault({ children });
const button = await screen.findByText(children);
expect(button).toBeTruthy();
// Check for chevron down icon (it should be in the DOM)
const chevronIcon = button.closest('button')?.querySelector('svg');
expect(chevronIcon).toBeTruthy();
});
test('should not show menu items by default', async () => {
renderDefault();
expect(screen.queryByRole('menuitem')).toBeNull();
});
test('should show menu items when clicked', async () => {
renderDefault();
const trigger = await screen.findByText('Test Menu');
fireEvent.click(trigger);
expect(await screen.findByText('Create new')).toBeTruthy();
expect(await screen.findByText('Edit existing')).toBeTruthy();
});
test('should hide menu items when something else is clicked', async () => {
renderDefault();
const trigger = await screen.findByText('Test Menu');
fireEvent.click(trigger);
expect(await screen.findByText('Create new')).toBeTruthy();
// click outside
fireEvent.mouseDown(document.body);
// items should disappear
expect(screen.queryByText('Create new')).toBeNull();
expect(screen.queryByText('Edit existing')).toBeNull();
});
test('should call onClick when menu item is clicked', async () => {
renderDefault();
const trigger = await screen.findByText('Test Menu');
fireEvent.click(trigger);
const createItem = await screen.findByText('Create new');
fireEvent.click(createItem);
expect(mockItems[0].handler).toHaveBeenCalled();
});
test('should not call onClick when disabled item is clicked', async () => {
const disabledItemMock = createMockMenuItem({
id: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn(),
});
renderDefault({ items: [disabledItemMock] });
const trigger = await screen.findByText('Test Menu');
fireEvent.click(trigger);
const disabledItem = await screen.findByText('Disabled action');
fireEvent.click(disabledItem);
expect(disabledItemMock.handler).not.toHaveBeenCalled();
});
test('should support link items', async () => {
const mockOnClick = vi.fn();
// Set up the mock to return our test onClick function
mockUseSref.mockReturnValue({
href: '#kubernetes.deploy',
onClick: mockOnClick,
});
render(
<UIRouter>
<MenuButton
items={[
<MenuButtonLink
key="docs"
to="kubernetes.deploy"
params={{}}
options={{}}
label="Docs"
data-cy="menu-button-link-docs"
>
Deploy
</MenuButtonLink>,
]}
color="primary"
data-cy="menu-button-link"
>
Mixed
</MenuButton>
</UIRouter>
);
const trigger = await screen.findByText('Mixed');
fireEvent.click(trigger);
const link = await screen.findByText('Deploy');
fireEvent.click(link);
expect(mockOnClick).toHaveBeenCalled();
});
test('should be disabled when disabled prop is true', async () => {
renderDefault({ disabled: true });
const button = await screen.findByText('Test Menu');
expect(button.closest('button')).toBeDisabled();
expect(button.closest('button')).toHaveClass('disabled');
});
test('should render menu items with icons', async () => {
renderDefault();
const trigger = await screen.findByText('Test Menu');
fireEvent.click(trigger);
// Check that menu items have icons (SVG elements)
const createItem = await screen.findByText('Create new');
const editItem = await screen.findByText('Edit existing');
expect(
createItem.closest('[role="menuitem"]')?.querySelector('svg')
).toBeTruthy();
expect(
editItem.closest('[role="menuitem"]')?.querySelector('svg')
).toBeTruthy();
});
test('should render with custom className', async () => {
renderDefault({ className: 'custom-class' });
const button = await screen.findByText('Test Menu');
expect(button.closest('button')).toHaveClass('custom-class');
});
test('should have proper accessibility attributes for screen readers', async () => {
const mockOnClick = vi.fn();
// Set up the mock to return our test onClick function
mockUseSref.mockReturnValue({
href: '#kubernetes.deploy',
onClick: mockOnClick,
});
render(
<UIRouter>
<MenuButton
items={[
<MenuButtonLink
key="docs"
to="kubernetes.deploy"
params={{}}
options={{}}
label="Deploy"
data-cy="menu-button-link-docs"
>
Deploy
</MenuButtonLink>,
]}
color="primary"
data-cy="menu-button-keyboard"
>
Accessibility Test
</MenuButton>
</UIRouter>
);
const trigger = await screen.findByText('Accessibility Test');
// Open menu to reveal the link
fireEvent.click(trigger);
const link = await screen.findByText('Deploy');
expect(link).toBeVisible();
// Test that the link has proper accessibility attributes
expect(link).toHaveAttribute('aria-label', 'Deploy'); // Screen reader support
expect(link).toHaveAttribute('role', 'menuitem'); // Proper ARIA role
expect(link).toHaveAttribute('href'); // Has navigation href
});
-100
View File
@@ -1,100 +0,0 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ChevronDown } from 'lucide-react';
import {
Menu,
MenuButton as ReachMenuButton,
MenuLink as ReachMenuLink,
MenuList,
} from '@reach/menu-button';
import clsx from 'clsx';
import { UISrefProps, useSref } from '@uirouter/react';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
import { Props as ButtonProps, ButtonWithRef } from './Button';
export interface MenuButtonProps
extends Omit<ButtonProps, 'onClick'>,
AutomationTestingProps {
items: Array<ReactNode>;
menuClassName?: string;
dropdownPosition?: 'left' | 'right';
children: ReactNode;
}
export function MenuButton({
items,
children,
color = 'primary',
size = 'small',
disabled = false,
className,
title,
icon,
menuClassName,
dropdownPosition = 'right',
'data-cy': dataCy,
}: PropsWithChildren<MenuButtonProps>) {
return (
<Menu>
<ReachMenuButton
as={ButtonWithRef}
color={color}
size={size}
disabled={disabled}
className={clsx('flex items-center gap-1', className)}
title={title}
icon={icon}
data-cy={dataCy}
>
{children}
<Icon icon={ChevronDown} size="xs" className="ml-1" />
</ReachMenuButton>
<MenuList
className={clsx(
'dropdown-menu relative rounded-lg !p-1',
'border !border-solid border-gray-6 th-dark:border-gray-warm-8 th-highcontrast:border-gray-2 shadow-[0_6px_12px_rgba(0,0,0,0.18)]',
{
'origin-top-right right-0': dropdownPosition === 'right',
'origin-top-left left-0': dropdownPosition === 'left',
},
menuClassName
)}
>
{items}
</MenuList>
</Menu>
);
}
interface MenuLinkProps extends AutomationTestingProps, UISrefProps {
children: ReactNode;
label?: string;
}
export function MenuButtonLink({
to,
children,
label,
params,
options,
'data-cy': dataCy,
}: MenuLinkProps) {
const anchorProps = useSref(to, params, options);
return (
<ReachMenuLink
href={anchorProps.href}
onClick={anchorProps.onClick}
className={clsx(
'rounded-md px-5 py-1 text-sm leading-5 whitespace-nowrap text-[var(--text-dropdown-menu-color)] decoration-none hover:decoration-none hover:bg-[var(--bg-dropdown-hover)] hover:text-[var(--text-dropdown-menu-color)] focus:bg-[var(--bg-dropdown-hover)] focus:text-[var(--text-dropdown-menu-color)] focus-visible:outline-none focus-visible:ring-0 hover:no-underline'
)}
aria-label={label}
data-cy={dataCy}
>
{children}
</ReachMenuLink>
);
}
-1
View File
@@ -3,4 +3,3 @@ export { AddButton } from './AddButton';
export { ButtonGroup } from './ButtonGroup';
export { LoadingButton } from './LoadingButton';
export { CopyButton } from './CopyButton';
export { MenuButton, MenuButtonLink } from './MenuButton';
@@ -2,7 +2,6 @@ import { AlertTriangle, Code, History, Minimize2 } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import LaptopCode from '@/assets/ico/laptop-code.svg?c';
import { useNamespaceAccessRedirect } from '@/react/kubernetes/namespaces/hooks/useNamespaceAccessRedirect';
import { PageHeader } from '@@/PageHeader';
import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
@@ -31,7 +30,6 @@ export function ApplicationDetailsView() {
const {
params: { namespace, name },
} = stateAndParams;
useNamespaceAccessRedirect(namespace, { to: 'kubernetes.applications' });
// placements table data
const { placementsData, isPlacementsTableLoading, hasPlacementWarning } =
@@ -201,7 +201,7 @@ function NodeDetailsForm({
submitLabel="Update node"
loadingText="Updating node..."
isLoading={isSubmitting}
isValid={isValid && !isSubmitting}
isValid={isValid && dirty && !isSubmitting}
data-cy="node-saveButton"
>
<Button
@@ -1,10 +1,8 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { Plus } from 'lucide-react';
import { AutomationTestingProps } from '@/types';
import { MenuButton, MenuButtonLink } from '@@/buttons/MenuButton';
import { Icon } from '@@/Icon';
import { AddButton } from '@@/buttons';
export function CreateFromManifestButton({
params = {},
@@ -12,37 +10,15 @@ export function CreateFromManifestButton({
}: { params?: object } & AutomationTestingProps) {
const { state } = useCurrentStateAndParams();
return (
<MenuButton
items={[
<MenuButtonLink
key="manifest"
to="kubernetes.deploy"
params={{
referrer: state.name,
...params,
}}
label="Create from manifest"
data-cy={`${dataCy}-manifest`}
>
Manifest
</MenuButtonLink>,
<MenuButtonLink
key="helm"
to="kubernetes.helminstall"
params={{
referrer: state.name,
...params,
}}
label="Create from Helm chart"
data-cy={`${dataCy}-helm`}
>
Helm chart
</MenuButtonLink>,
]}
<AddButton
to="kubernetes.deploy"
params={{
referrer: state.name,
...params,
}}
data-cy={dataCy}
>
<Icon icon={Plus} size="xs" />
Create from code
</MenuButton>
</AddButton>
);
}
@@ -59,7 +59,6 @@ const completeHelmRelease = {
},
info: {
status: 'deployed',
last_deployed: '2021-01-01T00:00:00Z',
notes: 'This is a test note',
resources: [
{
@@ -157,20 +156,6 @@ function createCommonHandlers() {
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces', () =>
HttpResponse.json([
{
Id: 'default',
Name: 'default',
Status: { phase: 'Active' },
Annotations: null,
CreationDate: '2021-01-01T00:00:00Z',
NamespaceOwner: '',
IsSystem: false,
IsDefault: true,
},
])
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
),
@@ -229,16 +214,19 @@ describe(
// Check for the page header
expect(await findByText('Helm details')).toBeInTheDocument();
// Check for the details content - these values should appear somewhere in the card
expect(await findByText('default')).toBeInTheDocument(); // namespace
expect(await findByText('test-chart-2.2.2')).toBeInTheDocument(); // chart version
expect(await findByText('test-chart')).toBeInTheDocument(); // chart name
expect(await findByText('#1')).toBeInTheDocument(); // revision
expect(await findByText('Jan 1, 2021, 12:00 AM')).toBeInTheDocument(); // last deployed
// Check for the badge content
expect(await findByText(/Namespace: default/)).toBeInTheDocument();
expect(
await findByText(/Chart version: test-chart-2.2.2/)
).toBeInTheDocument();
expect(await findByText(/Chart: test-chart/)).toBeInTheDocument();
expect(await findByText(/Revision: #1/)).toBeInTheDocument();
expect(
await findByText(/Last deployed: Jan 1, 2021, 12:00 AM/)
).toBeInTheDocument();
// Check for the actual values
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and breadcrumb
expect(await findAllByText(/test-chart/)).toHaveLength(2); // chart name appears twice
expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
expect(await findAllByText(/test-chart/)).toHaveLength(2); // title and badge (not checking revision list item)
// There shouldn't be a notes tab when there are no notes
expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
@@ -291,6 +279,13 @@ describe(
expect(await findByText('Helm details')).toBeInTheDocument();
// Check for the app version badge when it's available
await waitFor(() => {
expect(
screen.getByText(/App version/, { exact: false })
).toBeInTheDocument();
});
await waitFor(() => {
// Look for specific tab text
expect(screen.getByText('Resources')).toBeInTheDocument();
@@ -300,8 +295,7 @@ describe(
expect(screen.getByText('Events')).toBeInTheDocument();
});
// Check for the app version in the summary section
expect(await screen.findByText('1.0.0')).toBeInTheDocument();
expect(await findByText(/App version: 1.0.0/)).toBeInTheDocument();
});
},
{
@@ -5,7 +5,6 @@ import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Authorized } from '@/react/hooks/useUser';
import { useNamespaceAccessRedirect } from '@/react/kubernetes/namespaces/hooks/useNamespaceAccessRedirect';
import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
import { Card } from '@@/Card';
@@ -27,7 +26,6 @@ export function HelmApplicationView() {
const queryClient = useQueryClient();
const { params } = useCurrentStateAndParams();
const { name, namespace, revision } = params;
useNamespaceAccessRedirect(namespace, { to: 'kubernetes.applications' });
const helmHistoryQuery = useHelmHistory(environmentId, name, namespace);
const latestRevision = helmHistoryQuery.data?.[0]?.version;
const earlistRevision =
@@ -2,7 +2,6 @@ import { Badge } from '@/react/components/Badge';
import { localizeDate } from '@/react/common/date-utils';
import { Alert } from '@@/Alert';
import { Card } from '@@/Card';
import { HelmRelease } from '../types';
import {
@@ -22,7 +21,7 @@ export function HelmSummary({ release }: Props) {
return (
<div>
<div className="flex flex-col gap-y-4 mt-4">
<div className="flex flex-col gap-y-4">
<div>
<Badge type={getStatusColor(release.info?.status)}>
{getStatusText(release.info?.status)}
@@ -33,85 +32,33 @@ export function HelmSummary({ release }: Props) {
{release.info?.description}
</Alert>
)}
<Card>
<div className="form-section-title">Details</div>
<div
className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-1 text-sm"
data-cy="helm-release-info"
>
{!!release.namespace && (
<div className="min-w-0">
<span className="text-muted">Namespace: </span>
<span data-cy="helm-info-namespace">{release.namespace}</span>
</div>
)}
{!!release.version && (
<div className="min-w-0">
<span className="text-muted">Revision: </span>
<span data-cy="helm-info-revision">#{release.version}</span>
</div>
)}
{!!release.chart?.metadata?.name && (
<div className="min-w-0">
<span className="text-muted">Chart: </span>
<span data-cy="helm-info-chart">
{release.chart.metadata.name}
</span>
</div>
)}
<ChartReferenceBadge chartReference={release.chartReference} />
{!!release.chart?.metadata?.appVersion && (
<div className="min-w-0">
<span className="text-muted">App version: </span>
<span data-cy="helm-info-app-version">
{release.chart.metadata.appVersion}
</span>
</div>
)}
{!!release.chart?.metadata?.version && (
<div className="min-w-0">
<span className="text-muted">Chart version: </span>
<span className="inline-flex items-center gap-1 flex-wrap">
<span data-cy="helm-info-chart-version">
{release.chart.metadata.name}-
{release.chart.metadata.version}
</span>
</span>
</div>
)}
{!!release.info?.last_deployed && (
<div className="min-w-0">
<span className="text-muted">Last deployed: </span>
<span data-cy="helm-info-last-deployed">
{localizeDate(new Date(release.info.last_deployed))}
</span>
</div>
)}
</div>
</Card>
<div className="flex flex-wrap gap-2">
{!!release.namespace && <Badge>Namespace: {release.namespace}</Badge>}
{!!release.version && <Badge>Revision: #{release.version}</Badge>}
{!!release.chart?.metadata?.name && (
<Badge>Chart: {release.chart.metadata.name}</Badge>
)}
{!!release.chart?.metadata?.appVersion && (
<Badge>App version: {release.chart.metadata.appVersion}</Badge>
)}
{!!release.chart?.metadata?.version && (
<Badge>
Chart version: {release.chart.metadata.name}-
{release.chart.metadata.version}
</Badge>
)}
{!!release.info?.last_deployed && (
<Badge>
Last deployed:{' '}
{localizeDate(new Date(release.info.last_deployed))}
</Badge>
)}
</div>
</div>
</div>
);
}
function ChartReferenceBadge({
chartReference,
}: {
chartReference: HelmRelease['chartReference'];
}) {
// CE only supports Helm repositories (not OCI registries)
if (!chartReference?.repoURL) {
return null;
}
return (
<div className="min-w-0">
<span className="text-muted">Chart source: </span>
<span>{chartReference.repoURL}</span>
</div>
);
}
function getAlertColor(status?: string) {
switch (status?.toLowerCase()) {
case DeploymentStatus.DEPLOYED:
@@ -11,8 +11,8 @@ import {
RepoValue,
} from '../components/HelmRegistrySelect';
import { useHelmRepoOptions } from '../helmChartSourceQueries/useHelmRepositories';
import { HelmInstallForm } from '../install/HelmInstallForm';
import { HelmInstallForm } from './HelmInstallForm';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmTemplatesList } from './HelmTemplatesList';
@@ -2,6 +2,4 @@ export type HelmInstallFormValues = {
values: string;
version: string;
repo: string;
chartName?: string;
chartRepo?: string;
};
@@ -37,8 +37,7 @@ export function useHelmRelease<T = HelmRelease>(
revision,
}),
{
enabled:
!!environmentId && !!name && !!namespace && (options.enabled ?? true),
enabled: !!environmentId && !!name && !!namespace && options.enabled,
...withGlobalError('Unable to retrieve helm application details'),
retry: 3,
// occasionally the application shows before the release is created, take some more time to refetch
@@ -1,78 +0,0 @@
import { useState, useMemo } from 'react';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection';
import { HelmTemplates } from '../HelmTemplates/HelmTemplates';
export function HelmInstallView() {
const environmentId = useEnvironmentId();
const [namespace, setNamespace] = useState('');
const [name, setName] = useState('');
const namespacesQuery = useNamespacesQuery(environmentId);
const namespaces = useMemo(
() =>
Object.values(namespacesQuery.data ?? {}).map((ns) => ({
label: ns.Name,
value: ns.Name,
})),
[namespacesQuery.data]
);
const defaultNamespace =
namespaces.find((ns) => ns.value === 'default')?.value ||
namespaces[0]?.value ||
'';
// Set default namespace if not set
if (!namespace && defaultNamespace) {
setNamespace(defaultNamespace);
}
return (
<>
<PageHeader title="Helm install" breadcrumbs="Helm install" reload />
<div className="row">
<div className="col-sm-12 form-horizontal">
<Widget>
<WidgetBody>
<FormSection title="Deploy to">
<FormControl label="Namespace" required>
<PortainerSelect
value={namespace}
onChange={setNamespace}
options={namespaces}
data-cy="namespace-select"
/>
</FormControl>
<FormControl label="Release name" required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. my-app"
data-cy="k8sHelmInstall-nameInput"
/>
</FormControl>
</FormSection>
<HelmTemplates
namespace={namespace}
name={name}
onSelectHelmChart={() => {}}
/>
</WidgetBody>
</Widget>
</div>
</div>
</>
);
}
-11
View File
@@ -44,8 +44,6 @@ export interface HelmRelease {
version?: number;
/** Kubernetes namespace of the release */
namespace?: string;
/** Labels that identify the source of the chart (repo, path, etc.) */
chartReference?: ChartReference;
/** Values of the release */
values?: Values;
}
@@ -78,15 +76,6 @@ export interface HelmChart {
files?: unknown[];
}
export interface ChartReference {
/** Local or packaged chart path used during install/upgrade */
chartPath?: string;
/** Helm repository URL if the chart came from a repo (CE only) */
repoURL?: string;
/** Registry identifier if coming from an OCI registry */
registryID?: number;
}
export interface Chart extends HelmChartResponse {
repo: string;
}
@@ -9,7 +9,6 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
import { Annotation } from '@/react/kubernetes/annotations/types';
import { prepareAnnotations } from '@/react/kubernetes/utils';
import { useNamespaceAccessRedirect } from '@/react/kubernetes/namespaces/hooks/useNamespaceAccessRedirect';
import { Link } from '@@/Link';
import { PageHeader } from '@@/PageHeader';
@@ -44,7 +43,6 @@ import {
export function CreateIngressView() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
useNamespaceAccessRedirect(params.namespace, { to: 'kubernetes.ingresses' });
const { authorized: isAuthorizedToAddEdit } = useAuthorizations([
'K8sIngressesW',
]);
+1 -1
View File
@@ -64,7 +64,7 @@ export function useIngresses(
const { enabled, autoRefreshRate, ...params } = options ?? {};
return useQuery(
[...queryKeys.clusterIngresses(environmentId), params],
['environments', environmentId, 'kubernetes', 'ingress', params],
async () => getIngresses(environmentId, params),
{
...withGlobalError('Unable to get ingresses'),
@@ -2,7 +2,6 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { AlertTriangle, Code, Layers, History } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespaceAccessRedirect } from '@/react/kubernetes/namespaces/hooks/useNamespaceAccessRedirect';
import { PageHeader } from '@@/PageHeader';
import { findSelectedTabIndex, Tab, WidgetTabs } from '@@/Widget/WidgetTabs';
@@ -21,9 +20,6 @@ export function NamespaceView() {
const {
params: { id: namespace },
} = stateAndParams;
useNamespaceAccessRedirect(namespace, {
to: 'kubernetes.resourcePools',
});
const environmentId = useEnvironmentId();
const eventWarningCount = useEventWarningsCount(environmentId, { namespace });
@@ -1,51 +0,0 @@
import { useEffect } from 'react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
type RedirectOptions = {
to: string;
params?: Record<string, unknown>;
};
/**
* Redirects away when the provided namespace is not in the allowed namespaces list for the current environment.
*/
export function useNamespaceAccessRedirect(
namespace?: string,
{ to, params } = { to: 'kubernetes.dashboard', params: {} } as RedirectOptions
) {
const router = useRouter();
const namespaceInParams = useCurrentStateAndParams().params.namespace;
const currentNamespace = namespace || namespaceInParams;
const environmentId = useEnvironmentId();
const namespacesQuery = useNamespacesQuery(environmentId);
useEffect(() => {
if (!currentNamespace) {
return;
}
if (namespacesQuery.isLoading || namespacesQuery.isFetching) {
return;
}
const namespaces = namespacesQuery.data ?? [];
const isAllowed = namespaces.some((ns) => ns.Name === currentNamespace);
if (!isAllowed) {
router.stateService.go(to, params);
}
}, [
currentNamespace,
to,
params,
router.stateService,
namespacesQuery.isLoading,
namespacesQuery.isFetching,
namespacesQuery.data,
]);
}
@@ -25,7 +25,7 @@ export function ActivityLogsView() {
limit: tableState.pageSize,
sortBy: getSortType(tableState.sortBy?.id),
sortDesc: tableState.sortBy?.desc,
keyword: tableState.search,
search: tableState.search,
...(range
? {
after: seconds(range?.start?.valueOf()),
@@ -20,7 +20,7 @@ export interface Query {
limit: number;
sortBy?: SortKey;
sortDesc?: boolean;
keyword: string;
search: string;
after?: number;
before?: number;
}
@@ -14,7 +14,7 @@ export function useExportMutation() {
async function exportActivityLogs(query: Omit<Query, 'limit'>) {
try {
const { data, headers } = await axios.get<Blob>('/useractivity/logs.csv', {
params: { ...query, limit: 0 },
params: { ...query, limit: 2000 },
responseType: 'blob',
headers: {
'Content-type': 'text/csv',
@@ -34,6 +34,7 @@ describe('SettingsView', () => {
http.get('/api/settings/public', () =>
HttpResponse.json({
Features: {
'auto-patch': false,
'disable-roles-sync': false,
},
})
+3 -17
View File
@@ -23,11 +23,8 @@ GIT_COMMIT_HASH=${GIT_COMMIT_HASH:-$(git rev-parse --short HEAD)}
# populate dependencies versions
DOCKER_VERSION=$(jq -r '.docker' < "${BINARY_VERSION_FILE}")
KUBECTL_VERSION=$(jq -r '.kubectl' < "${BINARY_VERSION_FILE}")
COMPOSE_VERSION=$(go list -m -f '{{.Version}}' github.com/docker/compose/v2)
# Kubernetes SDK uses v0.x.y versioning, but official kubectl releases use v1.x.y
# We need to transform the version (e.g., v0.33.2 -> v1.33.2)
KUBECTL_VERSION=$(go list -modfile ../server-ce/go.mod -m -f '{{.Version}}' k8s.io/kubectl | sed 's/^v0\./v1./' | sed 's/^0\./1./')
HELM_VERSION=$(go list -modfile ../server-ce/go.mod -m -f '{{.Version}}' helm.sh/helm/v3)
# copy templates
cp -r "./mustache-templates" "./dist"
@@ -54,22 +51,11 @@ ldflags="-s -X 'github.com/portainer/liblicense.LicenseServerBaseURL=https://api
-X 'github.com/portainer/portainer/pkg/build.GoVersion=${GO_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepComposeVersion=${COMPOSE_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepDockerVersion=${DOCKER_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepKubectlVersion=${KUBECTL_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepHelmVersion=${HELM_VERSION}'"
-X 'github.com/portainer/portainer/pkg/build.DepKubectlVersion=${KUBECTL_VERSION}'"
echo "$ldflags"
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
# For a list of valid GOOS and GOARCH values
PLATFORM=${1:-$(go env GOOS)}
ARCH=${2:-$(go env GOARCH)}
# if the default platform is darwin, set it to linux to allow it to run in the portainer/base image (which doesn't support darwin)
if [ "$PLATFORM" = "darwin" ]; then
PLATFORM="linux"
fi
GOOS=${PLATFORM} GOARCH=${ARCH} CGO_ENABLED=0 go build \
GOOS=${1:-$(go env GOOS)} GOARCH=${2:-$(go env GOARCH)} CGO_ENABLED=0 go build \
-trimpath \
--installsuffix cgo \
--ldflags "$ldflags" \
+5 -5
View File
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.35.0",
"version": "2.34.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
@@ -76,7 +76,7 @@
"angularjs-scroll-glue": "^2.2.0",
"angularjs-slider": "^6.4.0",
"angulartics": "^1.6.0",
"axios": "^1.7",
"axios": "^1.6.2",
"axios-cache-interceptor": "^1.4.1",
"axios-progress-bar": "portainer/progress-bar-4-axios",
"babel-plugin-angularjs-annotate": "^0.10.0",
@@ -103,7 +103,7 @@
"jquery": "^3.6.0",
"js-base64": "^3.7.2",
"js-yaml": "^3.14.0",
"jsdom": "^24",
"jsdom": "^24.0.0",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"lucide-react": "^0.468.0",
@@ -170,7 +170,7 @@
"@types/uuid": "^3.3.2",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitest/coverage-v8": "~2.1.9",
"@vitest/coverage-v8": "^2.0.4",
"auto-ngtemplate-loader": "^3.1.2",
"autoprefixer": "^10.4.16",
"babel-loader": "^9.1.3",
@@ -224,7 +224,7 @@
"undici": "^6.2.1",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "~2.1.9",
"vitest": "^2.0.4",
"vitest-dom": "^0.1.1",
"webpack": "^5.88.2",
"webpack-build-notifier": "^2.3.0",
+1 -5
View File
@@ -44,9 +44,6 @@ var (
// DepKubectlVersion is the version of the Kubectl binary shipped with the application.
DepKubectlVersion string
// DepHelmVersion is the version of the Helm binary shipped with the application.
DepHelmVersion string
)
type (
@@ -92,9 +89,8 @@ func GetBuildInfo() BuildInfo {
func GetDependenciesInfo() DependenciesInfo {
return DependenciesInfo{
DockerVersion: DepDockerVersion,
ComposeVersion: DepComposeVersion,
KubectlVersion: DepKubectlVersion,
HelmVersion: DepHelmVersion,
ComposeVersion: DepComposeVersion,
}
}
+7 -20
View File
@@ -4,22 +4,16 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type InstallOptions struct {
Name string
Chart string
Version string
Namespace string
Repo string
Registry *portainer.Registry
Wait bool
// Values contains inline Helm values merged with the chart defaults.
// If both are provided, entries in Values override those from ValuesFile.
Values map[string]any
// ValuesFile is a path to a YAML file with Helm values to apply.
// File values are applied first; Values take precedence on conflicts.
Name string
Chart string
Version string
Namespace string
Repo string
Registry *portainer.Registry
Wait bool
ValuesFile string
PostRenderer string
Atomic bool
@@ -27,13 +21,6 @@ type InstallOptions struct {
Timeout time.Duration
KubernetesClusterAccess *KubernetesClusterAccess
// GitOps related options
GitConfig *gittypes.RepoConfig
AutoUpdate *portainer.AutoUpdateSettings
// StackID is the ID of the Portainer stack associated with this release
StackID int
// Optional environment vars to pass when running helm
Env []string
}
-12
View File
@@ -47,8 +47,6 @@ type Release struct {
Labels map[string]string `json:"-"`
// ChartReference are the labels that are used to identify the chart source.
ChartReference ChartReference `json:"chartReference,omitempty"`
// StackID is the ID of the Portainer stack associated with this release (if using GitOps)
StackID int `json:"stackID,omitempty"`
// Values are the values used to deploy the chart.
Values Values `json:"values,omitempty"`
}
@@ -64,16 +62,6 @@ type ChartReference struct {
RegistryID int64 `json:"registryID,omitempty"`
}
type GitReference struct {
Repo string `json:"repo,omitempty"`
Reference string `json:"reference,omitempty"`
CommitID string `json:"commitID,omitempty"`
StackID string `json:"stackID,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
AutoUpdateInterval string `json:"autoUpdateInterval,omitempty"`
TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"`
}
// Chart is a helm package that contains metadata, a default config, zero or more
// optionally parameterizable templates, and zero or more charts (dependencies).
type Chart struct {
+8
View File
@@ -5,6 +5,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/cli"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
@@ -148,6 +149,13 @@ func (c *clientConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return c.clientConfig
}
// parseValues parses YAML values data into a map
func (hspm *HelmSDKPackageManager) parseValues(data []byte) (map[string]any, error) {
// Use Helm's built-in chartutil.ReadValues which properly handles the conversion
// from map[interface{}]interface{} to map[string]interface{}
return chartutil.ReadValues(data)
}
// logf is a log helper function for Helm
func (hspm *HelmSDKPackageManager) logf(format string, v ...any) {
// Use zerolog for structured logging
+4 -3
View File
@@ -106,6 +106,7 @@ func Test_ClientConfigGetter(t *testing.T) {
func Test_ParseValues(t *testing.T) {
is := assert.New(t)
hspm := NewHelmSDKPackageManager()
t.Run("should parse valid YAML values", func(t *testing.T) {
yamlData := []byte(`
@@ -117,7 +118,7 @@ resources:
cpu: 100m
memory: 128Mi
`)
values, err := parseValues(yamlData)
values, err := hspm.parseValues(yamlData)
require.NoError(t, err, "should parse valid YAML without error")
is.NotNil(values, "should return non-nil values")
@@ -142,13 +143,13 @@ service:
port: 80
invalid yaml
`)
_, err := parseValues(yamlData)
_, err := hspm.parseValues(yamlData)
require.Error(t, err, "should return error for invalid YAML")
})
t.Run("should handle empty YAML", func(t *testing.T) {
yamlData := []byte(``)
values, err := parseValues(yamlData)
values, err := hspm.parseValues(yamlData)
require.NoError(t, err, "should not return error for empty YAML")
is.NotNil(values, "should return non-nil values for empty YAML")
is.Empty(values, "should return empty map for empty YAML")
+2 -10
View File
@@ -10,8 +10,6 @@ import (
"strings"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
@@ -27,9 +25,8 @@ import (
// Helm chart reference label constants
const (
ChartPathAnnotation = "portainer/chart-path"
RegistryIDAnnotation = "portainer/registry-id"
RepoURLAnnotation = "portainer/repo-url"
StackIDAnnotation = "portainer/stack-id"
RegistryIDAnnotation = "portainer/registry-id"
)
// loadAndValidateChartWithPathOptions locates and loads the chart, and validates it.
@@ -246,7 +243,7 @@ func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
// appendChartReferenceAnnotations encodes chart reference values for safe storage in Helm labels.
// It creates a new map with encoded values for specific chart reference labels.
// Preserves existing labels and handles edge cases gracefully.
func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int, stackID int, gitConfig *gittypes.RepoConfig, autoUpdateSettings *portainer.AutoUpdateSettings, existingAnnotations map[string]string) map[string]string {
func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int, existingAnnotations map[string]string) map[string]string {
// Copy existing annotations
annotations := make(map[string]string)
maps.Copy(annotations, existingAnnotations)
@@ -255,7 +252,6 @@ func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int,
delete(annotations, ChartPathAnnotation)
delete(annotations, RepoURLAnnotation)
delete(annotations, RegistryIDAnnotation)
delete(annotations, StackIDAnnotation)
if chartPath != "" {
annotations[ChartPathAnnotation] = chartPath
@@ -269,10 +265,6 @@ func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int,
annotations[RegistryIDAnnotation] = strconv.Itoa(registryID)
}
if stackID != 0 {
annotations[StackIDAnnotation] = strconv.Itoa(stackID)
}
return annotations
}
-102
View File
@@ -1,102 +0,0 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAppendChartReferenceAnnotations(t *testing.T) {
tests := []struct {
name string
chartPath string
repoURL string
registryID int
stackID int
existing map[string]string
want map[string]string
}{
{
name: "with registry ID",
chartPath: "charts/nginx",
registryID: 5,
stackID: 123,
want: map[string]string{
ChartPathAnnotation: "charts/nginx",
RegistryIDAnnotation: "5",
StackIDAnnotation: "123",
// repoURL is NOT added when registryID != 0
},
},
{
name: "with repo URL (no registry)",
chartPath: "charts/nginx",
repoURL: "https://charts.example.com",
stackID: 123,
want: map[string]string{
ChartPathAnnotation: "charts/nginx",
RepoURLAnnotation: "https://charts.example.com",
StackIDAnnotation: "123",
},
},
{
name: "preserves custom annotations",
chartPath: "my-chart",
existing: map[string]string{"custom": "value"},
want: map[string]string{
ChartPathAnnotation: "my-chart",
"custom": "value",
},
},
{
name: "replaces old chart annotations",
chartPath: "new-chart",
repoURL: "https://new.com",
existing: map[string]string{
ChartPathAnnotation: "old-chart",
RepoURLAnnotation: "https://old.com",
},
want: map[string]string{
ChartPathAnnotation: "new-chart",
RepoURLAnnotation: "https://new.com",
},
},
{
name: "omits zero values",
chartPath: "chart",
want: map[string]string{
ChartPathAnnotation: "chart",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := appendChartReferenceAnnotations(
tt.chartPath, tt.repoURL, tt.registryID, tt.stackID,
nil, nil, tt.existing,
)
assert.Equal(t, tt.want, result)
})
}
}
func TestAppendChartReferenceAnnotations_RepoURLLogic(t *testing.T) {
t.Run("repoURL only added when registryID is zero", func(t *testing.T) {
// With registry ID - no repoURL
result := appendChartReferenceAnnotations("chart", "url", 5, 0, nil, nil, nil)
_, hasRepoURL := result[RepoURLAnnotation]
assert.False(t, hasRepoURL)
// Without registry ID - includes repoURL
result = appendChartReferenceAnnotations("chart", "url", 0, 0, nil, nil, nil)
assert.Equal(t, "url", result[RepoURLAnnotation])
})
t.Run("does not mutate existing map", func(t *testing.T) {
existing := map[string]string{"key": "value"}
appendChartReferenceAnnotations("chart", "", 0, 0, nil, nil, existing)
assert.Equal(t, map[string]string{"key": "value"}, existing)
})
}
+1 -22
View File
@@ -1,8 +1,6 @@
package sdk
import (
"strconv"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/time"
@@ -80,23 +78,7 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel
Str("name", sdkRelease.Name).
Err(err).Msg("Failed to parse resources")
}
// Parse stack ID from annotations -> int
stackID := 0
if sdkRelease.Chart != nil && sdkRelease.Chart.Metadata != nil {
if s, ok := sdkRelease.Chart.Metadata.Annotations[StackIDAnnotation]; ok && s != "" {
if id, err := strconv.Atoi(s); err == nil {
stackID = id
} else {
log.Warn().
Str("context", "HelmClient").
Str("namespace", sdkRelease.Namespace).
Str("name", sdkRelease.Name).
Err(err).Msg("Failed to parse stack id from annotations")
}
}
}
release := &release.Release{
return &release.Release{
Name: sdkRelease.Name,
Namespace: sdkRelease.Namespace,
Version: sdkRelease.Version,
@@ -117,8 +99,5 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel
},
Values: values,
ChartReference: extractChartReferenceAnnotations(sdkRelease.Chart.Metadata.Annotations),
StackID: stackID,
}
return release
}
-144
View File
@@ -36,148 +36,4 @@ func Test_Convert(t *testing.T) {
result := convert(&release, values)
is.Equal(release.Name, result.Name)
})
t.Run("extracts stack ID from annotations", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "stack-release",
Namespace: "app-namespace",
Version: 2,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "myapp",
Version: "2.1.0",
Annotations: map[string]string{
StackIDAnnotation: "123",
ChartPathAnnotation: "charts/myapp",
RepoURLAnnotation: "https://github.com/company/charts",
},
},
},
}
result := convert(&release, libhelmrelease.Values{})
is.NotNil(result)
is.Equal(123, result.StackID)
is.Equal("charts/myapp", result.ChartReference.ChartPath)
is.Equal("https://github.com/company/charts", result.ChartReference.RepoURL)
})
t.Run("handles invalid stack ID gracefully", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "release",
Namespace: "default",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart",
Version: "1.0.0",
Annotations: map[string]string{
StackIDAnnotation: "not-a-number",
},
},
},
}
result := convert(&release, libhelmrelease.Values{})
is.NotNil(result)
// Should default to 0 when parsing fails
is.Equal(0, result.StackID)
})
t.Run("handles empty stack ID annotation", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "release",
Namespace: "default",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart",
Version: "1.0.0",
Annotations: map[string]string{
StackIDAnnotation: "",
},
},
},
}
result := convert(&release, libhelmrelease.Values{})
is.NotNil(result)
is.Equal(0, result.StackID)
})
t.Run("handles missing annotations", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "release",
Namespace: "default",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart",
Version: "1.0.0",
Annotations: nil,
},
},
}
result := convert(&release, libhelmrelease.Values{})
is.NotNil(result)
is.Equal(0, result.StackID)
})
// Note: We don't test nil chart metadata or nil chart cases because
// the Helm SDK never returns releases in those states. The convert function
// assumes valid Helm SDK releases, which is acceptable for internal use.
t.Run("extracts registry ID from annotations", func(t *testing.T) {
is := assert.New(t)
release := sdkrelease.Release{
Name: "release",
Namespace: "default",
Version: 1,
Info: &sdkrelease.Info{
Status: "deployed",
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart",
Version: "1.0.0",
Annotations: map[string]string{
RegistryIDAnnotation: "42",
ChartPathAnnotation: "oci://registry.example.com/charts/myapp",
},
},
},
}
result := convert(&release, libhelmrelease.Values{})
is.NotNil(result)
is.Equal(int64(42), result.ChartReference.RegistryID)
is.Equal("oci://registry.example.com/charts/myapp", result.ChartReference.ChartPath)
})
}
+8 -14
View File
@@ -58,18 +58,13 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
}
var values map[string]any
if installOpts.Values != nil {
values = installOpts.Values
} else {
values, err = GetHelmValuesFromFile(installOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release installation")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
values, err := hspm.getHelmValuesFromFile(installOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release installation")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
chartRef, repoURL, err := parseChartRef(installOpts.Chart, installOpts.Repo, installOpts.Registry)
@@ -100,7 +95,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
if installOpts.Registry != nil {
registryID = int(installOpts.Registry.ID)
}
chart.Metadata.Annotations = appendChartReferenceAnnotations(installOpts.Chart, installOpts.Repo, registryID, installOpts.StackID, installOpts.GitConfig, installOpts.AutoUpdate, chart.Metadata.Annotations)
chart.Metadata.Annotations = appendChartReferenceAnnotations(installOpts.Chart, installOpts.Repo, registryID, chart.Metadata.Annotations)
// Run the installation
log.Info().
@@ -109,7 +104,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Msg("Running chart installation for helm release")
helmRelease, err := installClient.Run(chart, values)
if err != nil {
log.Error().
+9 -14
View File
@@ -82,18 +82,13 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade")
}
var values map[string]any
if upgradeOpts.Values != nil {
values = upgradeOpts.Values
} else {
values, err = GetHelmValuesFromFile(upgradeOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release upgrade")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
}
values, err := hspm.getHelmValuesFromFile(upgradeOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release upgrade")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
}
chartRef, repoURL, err := parseChartRef(upgradeOpts.Chart, upgradeOpts.Repo, upgradeOpts.Registry)
@@ -124,14 +119,14 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
if upgradeOpts.Registry != nil {
registryID = int(upgradeOpts.Registry.ID)
}
chart.Metadata.Annotations = appendChartReferenceAnnotations(upgradeOpts.Chart, upgradeOpts.Repo, registryID, upgradeOpts.StackID, upgradeOpts.GitConfig, upgradeOpts.AutoUpdate, chart.Metadata.Annotations)
chart.Metadata.Annotations = appendChartReferenceAnnotations(upgradeOpts.Chart, upgradeOpts.Repo, registryID, chart.Metadata.Annotations)
log.Info().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Msgf("Running chart upgrade for helm release with the annotations: %+v", chart.Metadata.Annotations)
Msg("Running chart upgrade for helm release")
helmRelease, err := upgradeClient.Run(upgradeOpts.Name, chart, values)
if err != nil {
+3 -33
View File
@@ -9,12 +9,11 @@ import (
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chartutil"
)
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
// getHelmValuesFromFile reads the values file and parses it into a map[string]any
// and returns the map.
func GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
func (hspm *HelmSDKPackageManager) getHelmValuesFromFile(valuesFile string) (map[string]any, error) {
var vals map[string]any
if valuesFile != "" {
log.Debug().
@@ -32,7 +31,7 @@ func GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
return nil, errors.Wrap(err, "failed to read values file")
}
vals, err = parseValues(valuesData)
vals, err = hspm.parseValues(valuesData)
if err != nil {
log.Error().
Str("context", "HelmClient").
@@ -46,35 +45,6 @@ func GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
return vals, nil
}
// parseValues parses YAML values data into a map
func parseValues(data []byte) (map[string]any, error) {
// Use Helm's built-in chartutil.ReadValues which properly handles the conversion
// from map[interface{}]interface{} to map[string]interface{}
return chartutil.ReadValues(data)
}
// MergeValues merges two maps recursively, with values from the override map taking precedence
// over values from the base map. It returns a new map containing the merged values.
func MergeValues(base, override map[string]any) map[string]any {
if base == nil {
return override
}
if override == nil {
return base
}
for k, v := range override {
if vMap, ok := v.(map[string]any); ok {
if baseMap, ok := base[k].(map[string]any); ok {
base[k] = MergeValues(baseMap, vMap)
continue
}
}
base[k] = v
}
return base
}
func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (release.Values, error) {
log.Debug().
Str("context", "HelmClient").
-242
View File
@@ -1,242 +0,0 @@
package sdk
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeValues(t *testing.T) {
tests := []struct {
name string
base map[string]any
override map[string]any
expected map[string]any
}{
{
name: "empty base returns override",
base: nil,
override: map[string]any{"key": "value"},
expected: map[string]any{"key": "value"},
},
{
name: "empty override returns base",
base: map[string]any{"key": "value"},
override: nil,
expected: map[string]any{"key": "value"},
},
{
name: "both nil returns nil",
base: nil,
override: nil,
expected: nil,
},
{
name: "simple merge without conflicts",
base: map[string]any{"key1": "value1"},
override: map[string]any{"key2": "value2"},
expected: map[string]any{"key1": "value1", "key2": "value2"},
},
{
name: "override replaces scalar value",
base: map[string]any{"key": "old"},
override: map[string]any{"key": "new"},
expected: map[string]any{"key": "new"},
},
{
name: "nested map merge preserves non-conflicting keys",
base: map[string]any{
"config": map[string]any{
"port": 8080,
"host": "localhost",
},
},
override: map[string]any{
"config": map[string]any{
"port": 9090,
},
},
expected: map[string]any{
"config": map[string]any{
"port": 9090,
"host": "localhost",
},
},
},
{
name: "deep nested merge",
base: map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"key1": "value1",
"key2": "value2",
},
"other": "data",
},
},
override: map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"key2": "overridden",
"key3": "new",
},
},
},
expected: map[string]any{
"level1": map[string]any{
"level2": map[string]any{
"key1": "value1",
"key2": "overridden",
"key3": "new",
},
"other": "data",
},
},
},
{
name: "override replaces scalar with map",
base: map[string]any{
"value": "simple",
},
override: map[string]any{
"value": map[string]any{
"complex": "object",
},
},
expected: map[string]any{
"value": map[string]any{
"complex": "object",
},
},
},
{
name: "override replaces map with scalar",
base: map[string]any{
"value": map[string]any{
"complex": "object",
},
},
override: map[string]any{
"value": "simple",
},
expected: map[string]any{
"value": "simple",
},
},
{
name: "arrays are replaced not merged",
base: map[string]any{
"items": []any{"a", "b", "c"},
},
override: map[string]any{
"items": []any{"x", "y"},
},
expected: map[string]any{
"items": []any{"x", "y"},
},
},
{
name: "helm typical scenario - image override",
base: map[string]any{
"replicaCount": 1,
"image": map[string]any{
"repository": "nginx",
"tag": "latest",
"pullPolicy": "IfNotPresent",
},
"service": map[string]any{
"type": "ClusterIP",
"port": 80,
},
},
override: map[string]any{
"replicaCount": 3,
"image": map[string]any{
"tag": "v1.2.3",
},
"service": map[string]any{
"type": "LoadBalancer",
},
},
expected: map[string]any{
"replicaCount": 3,
"image": map[string]any{
"repository": "nginx",
"tag": "v1.2.3",
"pullPolicy": "IfNotPresent",
},
"service": map[string]any{
"type": "LoadBalancer",
"port": 80,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeValues(tt.base, tt.override)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetHelmValuesFromFile(t *testing.T) {
// Create a temporary directory for test files
tempDir, err := os.MkdirTemp("", "helm-values-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Run("empty file path returns empty map", func(t *testing.T) {
vals, err := GetHelmValuesFromFile("")
require.NoError(t, err)
assert.Nil(t, vals)
})
t.Run("non-existent file returns error", func(t *testing.T) {
_, err := GetHelmValuesFromFile(filepath.Join(tempDir, "nonexistent.yaml"))
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read values file")
})
t.Run("valid values file", func(t *testing.T) {
valuesPath := filepath.Join(tempDir, "values.yaml")
valuesContent := []byte(`
replicaCount: 3
image:
repository: nginx
tag: v1.0.0
service:
port: 8080
`)
err := os.WriteFile(valuesPath, valuesContent, 0644)
require.NoError(t, err)
vals, err := GetHelmValuesFromFile(valuesPath)
require.NoError(t, err)
assert.NotNil(t, vals)
// YAML parser returns numbers as float64
assert.InDelta(t, float64(3), vals["replicaCount"], 1e-9)
image, ok := vals["image"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "nginx", image["repository"])
assert.Equal(t, "v1.0.0", image["tag"])
})
t.Run("invalid YAML in file returns error", func(t *testing.T) {
invalidPath := filepath.Join(tempDir, "invalid.yaml")
invalidContent := []byte(`
invalid: yaml: content: [[[
`)
err := os.WriteFile(invalidPath, invalidContent, 0644)
require.NoError(t, err)
_, err = GetHelmValuesFromFile(invalidPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse values file")
})
}
-4
View File
@@ -121,10 +121,6 @@ func (hpm helmMockPackageManager) Get(getOpts options.GetOptions) (*release.Rele
return re.Name == getOpts.Name && re.Namespace == getOpts.Namespace
})
if index == -1 {
return nil, errors.Errorf("release %s not found in namespace %s", getOpts.Name, getOpts.Namespace)
}
return newMockRelease(&mockCharts[index]), nil
}
+4 -4
View File
@@ -15,13 +15,13 @@ import (
const (
// ErrInvalidQueryParameter defines the message of an error raised when a mandatory query parameter has an invalid value.
ErrInvalidQueryParameter = "invalid query parameter"
ErrInvalidQueryParameter = "Invalid query parameter"
// ErrInvalidRequestURL defines the message of an error raised when the data sent in the query or the URL is invalid
ErrInvalidRequestURL = "invalid request URL"
ErrInvalidRequestURL = "Invalid request URL"
// ErrMissingQueryParameter defines the message of an error raised when a mandatory query parameter is missing.
ErrMissingQueryParameter = "missing query parameter"
ErrMissingQueryParameter = "Missing query parameter"
// ErrMissingFormDataValue defines the message of an error raised when a mandatory form data value is missing.
ErrMissingFormDataValue = "missing form data value"
ErrMissingFormDataValue = "Missing form data value"
)
// RetrieveMultiPartFormFile returns the content of an uploaded file (form data) as bytes as well
+1 -2
View File
@@ -46,8 +46,7 @@ func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecu
return nil, errors.New("must provide either a kubeconfig path or a server")
}
// Pass 'false' to usePersistentConfig to prevent memory leaks.
configFlags := genericclioptions.NewConfigFlags(false)
configFlags := genericclioptions.NewConfigFlags(true)
if namespace != "" {
configFlags.Namespace = &namespace
}
-56
View File
@@ -1,56 +0,0 @@
package libkubectl
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGenerateConfigFlags(t *testing.T) {
config, err := generateConfigFlags("test-token", "https://api.example.com", "", "", false)
require.NoError(t, err)
require.NotNil(t, config)
_, err = generateConfigFlags("test-token", "", "", "", false)
require.Error(t, err)
}
func TestNewClient(t *testing.T) {
// Test with server and token
client, err := NewClient(&ClientAccess{
Token: "test-token",
ServerUrl: "https://api.example.com",
}, "", "", false)
require.NoError(t, err)
require.NotNil(t, client)
// Verify the client has the expected structure for a Kubernetes client
require.NotNil(t, client.factory, "Expected factory to be set")
require.NotNil(t, client.streams, "Expected streams to be set")
require.NotNil(t, client.out, "Expected output buffer to be set")
}
func TestNewClientWithKubeconfig(t *testing.T) {
// Test with kubeconfig path
client, err := NewClient(&ClientAccess{
Token: "",
ServerUrl: "",
}, "test-namespace", "/path/to/kubeconfig", true)
require.NoError(t, err)
require.NotNil(t, client)
// Verify the client has the expected structure for a Kubernetes client
require.NotNil(t, client.factory, "Expected factory to be set")
require.NotNil(t, client.streams, "Expected streams to be set")
require.NotNil(t, client.out, "Expected output buffer to be set")
}
func TestNewClientError(t *testing.T) {
// Test error case when both server and kubeconfig are empty
client, err := NewClient(&ClientAccess{
Token: "",
ServerUrl: "",
}, "", "", false)
require.Error(t, err)
require.Nil(t, client)
}

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