Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b66cf636 | |||
| e02296ed50 |
@@ -95,7 +95,6 @@ body:
|
||||
multiple: false
|
||||
options:
|
||||
- '2.34.0'
|
||||
- '2.33.2'
|
||||
- '2.33.1'
|
||||
- '2.33.0'
|
||||
- '2.32.0'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))), [])
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user