diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 75414fa04..4324bf5a8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,11 +94,6 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: - - '2.32.0' - - '2.31.3' - - '2.31.2' - - '2.31.1' - - '2.31.0' - '2.30.1' - '2.30.0' - '2.29.2' @@ -106,9 +101,6 @@ body: - '2.29.0' - '2.28.1' - '2.28.0' - - '2.27.9' - - '2.27.8' - - '2.27.7' - '2.27.6' - '2.27.5' - '2.27.4' diff --git a/api/cli/cli.go b/api/cli/cli.go index 0722c0b2e..f6035f298 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -61,8 +61,6 @@ func CLIFlags() *portainer.CLIFlags { LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"), KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), 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(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 818025bdf..6261efbd9 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -52,7 +52,6 @@ import ( "github.com/portainer/portainer/pkg/libhelm" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" "github.com/portainer/portainer/pkg/libstack/compose" - "github.com/portainer/portainer/pkg/validate" "github.com/gofrs/uuid" "github.com/rs/zerolog/log" @@ -331,18 +330,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) } - trustedOrigins := []string{} - if *flags.TrustedOrigins != "" { - // validate if the trusted origins are valid urls - for _, origin := range strings.Split(*flags.TrustedOrigins, ",") { - if !validate.IsTrustedOrigin(origin) { - log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.") - } - - trustedOrigins = append(trustedOrigins, origin) - } - } - fileService := initFileService(*flags.Data) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) if encryptionKey == nil { @@ -383,8 +370,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { gitService := git.NewService(shutdownCtx) - // Setting insecureSkipVerify to true to preserve the old behaviour. - openAMTService := openamt.NewService(true) + openAMTService := openamt.NewService() cryptoService := &crypto.Service{} @@ -451,7 +437,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { snapshotService.Start() - proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService) + proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService) helmPackageManager, err := initHelmPackageManager() if err != nil { @@ -559,7 +545,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { Status: applicationStatus, BindAddress: *flags.Addr, BindAddressHTTPS: *flags.AddrHTTPS, - CSP: *flags.CSP, HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, @@ -593,7 +578,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { PendingActionsService: pendingActionsService, PlatformService: platformService, PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, - TrustedOrigins: trustedOrigins, } } diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go index 32a1b55c3..a0db7f4e0 100644 --- a/api/database/boltdb/db.go +++ b/api/database/boltdb/db.go @@ -138,8 +138,6 @@ func (connection *DbConnection) Open() error { 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 diff --git a/api/dataservices/base.go b/api/dataservices/base.go index 18839b60f..04af70b02 100644 --- a/api/dataservices/base.go +++ b/api/dataservices/base.go @@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface { Create(element *T) error Read(ID I) (*T, error) Exists(ID I) (bool, error) - ReadAll(predicates ...func(T) bool) ([]T, error) + ReadAll() ([]T, error) Update(ID I, element *T) error Delete(ID I) error } @@ -56,13 +56,12 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) { return exists, err } -// ReadAll retrieves all the elements that satisfy all the provided predicates. -func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { +func (service BaseDataService[T, I]) ReadAll() ([]T, error) { var collection = make([]T, 0) return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error { var err error - collection, err = service.Tx(tx).ReadAll(predicates...) + collection, err = service.Tx(tx).ReadAll() return err }) diff --git a/api/dataservices/base_test.go b/api/dataservices/base_test.go deleted file mode 100644 index e97a09963..000000000 --- a/api/dataservices/base_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package dataservices - -import ( - "strconv" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/slicesx" - - "github.com/stretchr/testify/require" -) - -type testObject struct { - ID int - Value int -} - -type mockConnection struct { - store map[int]testObject - - portainer.Connection -} - -func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error { - obj := value.(*testObject) - - m.store[obj.ID] = *obj - - return nil -} - -func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { - for _, v := range m.store { - if _, err := appendFn(&v); err != nil { - return err - } - } - - return nil -} - -func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error { - return fn(m) -} - -func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error { - return fn(m) -} - -func (m mockConnection) ConvertToKey(v int) []byte { - return []byte(strconv.Itoa(v)) -} - -func TestReadAll(t *testing.T) { - service := BaseDataService[testObject, int]{ - Bucket: "testBucket", - Connection: mockConnection{store: make(map[int]testObject)}, - } - - data := []testObject{ - {ID: 1, Value: 1}, - {ID: 2, Value: 2}, - {ID: 3, Value: 3}, - {ID: 4, Value: 4}, - {ID: 5, Value: 5}, - } - - for _, item := range data { - err := service.Update(item.ID, &item) - require.NoError(t, err) - } - - // ReadAll without predicates - result, err := service.ReadAll() - require.NoError(t, err) - - expected := append([]testObject{}, data...) - - require.ElementsMatch(t, expected, result) - - // ReadAll with predicates - hasLowID := func(obj testObject) bool { return obj.ID < 3 } - isEven := func(obj testObject) bool { return obj.Value%2 == 0 } - - result, err = service.ReadAll(hasLowID, isEven) - require.NoError(t, err) - - expected = slicesx.Filter(expected, hasLowID) - expected = slicesx.Filter(expected, isEven) - - require.ElementsMatch(t, expected, result) -} diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go index 5d7e7eee0..d9915b64c 100644 --- a/api/dataservices/base_tx.go +++ b/api/dataservices/base_tx.go @@ -34,32 +34,13 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) { return service.Tx.KeyExists(service.Bucket, identifier) } -// ReadAll retrieves all the elements that satisfy all the provided predicates. -func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { +func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) { var collection = make([]T, 0) - if len(predicates) == 0 { - return collection, service.Tx.GetAll( - service.Bucket, - new(T), - AppendFn(&collection), - ) - } - - filterFn := func(element T) bool { - for _, p := range predicates { - if !p(element) { - return false - } - } - - return true - } - return collection, service.Tx.GetAll( service.Bucket, new(T), - FilterFn(&collection, filterFn), + AppendFn(&collection), ) } diff --git a/api/dataservices/edgegroup/tx.go b/api/dataservices/edgegroup/tx.go index 2fba688a6..19f37e011 100644 --- a/api/dataservices/edgegroup/tx.go +++ b/api/dataservices/edgegroup/tx.go @@ -17,29 +17,11 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun } func (service ServiceTx) Create(group *portainer.EdgeGroup) error { - es := group.Endpoints - group.Endpoints = nil // Clear deprecated field - - err := service.Tx.CreateObject( + return service.Tx.CreateObject( BucketName, func(id uint64) (int, any) { group.ID = portainer.EdgeGroupID(id) return int(group.ID), group }, ) - - group.Endpoints = es // Restore endpoints after create - - return err -} - -func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { - es := group.Endpoints - group.Endpoints = nil // Clear deprecated field - - err := service.BaseDataServiceTx.Update(ID, group) - - group.Endpoints = es // Restore endpoints after update - - return err } diff --git a/api/dataservices/endpointrelation/endpointrelation.go b/api/dataservices/endpointrelation/endpointrelation.go index 556a046bb..a81c258b9 100644 --- a/api/dataservices/endpointrelation/endpointrelation.go +++ b/api/dataservices/endpointrelation/endpointrelation.go @@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, } func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } diff --git a/api/datastore/migrate_data.go b/api/datastore/migrate_data.go index 2b53bbb9c..409936db8 100644 --- a/api/datastore/migrate_data.go +++ b/api/datastore/migrate_data.go @@ -85,7 +85,6 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai EdgeStackService: store.EdgeStackService, EdgeStackStatusService: store.EdgeStackStatusService, EdgeJobService: store.EdgeJobService, - EdgeGroupService: store.EdgeGroupService, TunnelServerService: store.TunnelServerService, PendingActionsService: store.PendingActionsService, } diff --git a/api/datastore/migrator/migrate_2_32_0.go b/api/datastore/migrator/migrate_2_32_0.go deleted file mode 100644 index c32a63cad..000000000 --- a/api/datastore/migrator/migrate_2_32_0.go +++ /dev/null @@ -1,33 +0,0 @@ -package migrator - -import ( - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" - perrors "github.com/portainer/portainer/api/dataservices/errors" - "github.com/portainer/portainer/api/internal/endpointutils" -) - -func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error { - endpoints, err := m.endpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - if endpointutils.IsEdgeEndpoint(&endpoint) { - _, err := m.endpointRelationService.EndpointRelation(endpoint.ID) - if err != nil && errors.Is(err, perrors.ErrObjectNotFound) { - relation := &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - - if err := m.endpointRelationService.Create(relation); err != nil { - return err - } - } - } - } - - return nil -} diff --git a/api/datastore/migrator/migrate_2_33_0.go b/api/datastore/migrator/migrate_2_33_0.go deleted file mode 100644 index f000a780a..000000000 --- a/api/datastore/migrator/migrate_2_33_0.go +++ /dev/null @@ -1,23 +0,0 @@ -package migrator - -import ( - "github.com/portainer/portainer/api/roar" -) - -func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error { - egs, err := m.edgeGroupService.ReadAll() - if err != nil { - return err - } - - for _, eg := range egs { - eg.EndpointIDs = roar.FromSlice(eg.Endpoints) - eg.Endpoints = nil - - if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil { - return err - } - } - - return nil -} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index df27cc0cd..992dd0b9d 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -6,7 +6,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/dataservices/dockerhub" - "github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgestack" "github.com/portainer/portainer/api/dataservices/edgestackstatus" @@ -61,7 +60,6 @@ type ( edgeStackService *edgestack.Service edgeStackStatusService *edgestackstatus.Service edgeJobService *edgejob.Service - edgeGroupService *edgegroup.Service TunnelServerService *tunnelserver.Service pendingActionsService *pendingactions.Service } @@ -91,7 +89,6 @@ type ( EdgeStackService *edgestack.Service EdgeStackStatusService *edgestackstatus.Service EdgeJobService *edgejob.Service - EdgeGroupService *edgegroup.Service TunnelServerService *tunnelserver.Service PendingActionsService *pendingactions.Service } @@ -123,13 +120,11 @@ func NewMigrator(parameters *MigratorParameters) *Migrator { edgeStackService: parameters.EdgeStackService, edgeStackStatusService: parameters.EdgeStackStatusService, edgeJobService: parameters.EdgeJobService, - edgeGroupService: parameters.EdgeGroupService, TunnelServerService: parameters.TunnelServerService, pendingActionsService: parameters.PendingActionsService, } migrator.initMigrations() - return migrator } @@ -254,10 +249,6 @@ func (m *Migrator) initMigrations() { m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0) - m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0) - - m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0) - // Add new migrations above... // One function per migration, each versions migration funcs in the same file. } diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 5e8b0eefa..41a1b49df 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -121,10 +121,6 @@ "Ecr": { "Region": "" }, - "Github": { - "OrganisationName": "", - "UseOrganisation": false - }, "Gitlab": { "InstanceURL": "", "ProjectId": 0, @@ -615,7 +611,7 @@ "RequiredPasswordLength": 12 }, "KubeconfigExpiry": "0", - "KubectlShellImage": "portainer/kubectl-shell:2.32.0", + "KubectlShellImage": "portainer/kubectl-shell:2.31.0", "LDAPSettings": { "AnonymousMode": true, "AutoCreateUsers": true, @@ -780,7 +776,6 @@ "ImageCount": 9, "IsPodman": false, "NodeCount": 0, - "PerformanceMetrics": null, "RunningContainerCount": 5, "ServiceCount": 0, "StackCount": 2, @@ -944,7 +939,7 @@ } ], "version": { - "VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + "VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" }, "webhooks": null } \ No newline at end of file diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 5de18b303..3e297a129 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -58,15 +58,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) { t.Run(tt.name, func(t *testing.T) { dst := t.TempDir() repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password) - err := service.CloneRepository( - dst, - repositoryUrl, - tt.args.referenceName, - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "", false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) }) @@ -81,15 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) { dst := t.TempDir() - err := service.CloneRepository( - dst, - privateAzureRepoURL, - "refs/heads/main", - "", - pat, - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, privateAzureRepoURL, "refs/heads/main", "", pat, false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } @@ -100,14 +84,7 @@ func TestService_LatestCommitID_Azure(t *testing.T) { pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") service := NewService(context.TODO()) - id, err := service.LatestCommitID( - privateAzureRepoURL, - "refs/heads/main", - "", - pat, - gittypes.GitCredentialAuthType_Basic, - false, - ) + id, err := service.LatestCommitID(privateAzureRepoURL, "refs/heads/main", "", pat, false) assert.NoError(t, err) assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") } @@ -119,14 +96,7 @@ func TestService_ListRefs_Azure(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := NewService(context.TODO()) - refs, err := service.ListRefs( - privateAzureRepoURL, - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - ) + refs, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) } @@ -138,8 +108,8 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + go service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) + service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) time.Sleep(2 * time.Second) } @@ -277,17 +247,7 @@ func TestService_ListFiles_Azure(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, err := service.ListFiles( - tt.args.repositoryUrl, - tt.args.referenceName, - tt.args.username, - tt.args.password, - gittypes.GitCredentialAuthType_Basic, - false, - false, - tt.extensions, - false, - ) + paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) if tt.expect.shouldFail { assert.Error(t, err) if tt.expect.err != nil { @@ -310,28 +270,8 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListFiles( - privateAzureRepoURL, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) - service.ListFiles( - privateAzureRepoURL, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) + service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) time.Sleep(2 * time.Second) } diff --git a/api/git/backup.go b/api/git/backup.go index 6928f521a..286b51876 100644 --- a/api/git/backup.go +++ b/api/git/backup.go @@ -19,7 +19,6 @@ type CloneOptions struct { ReferenceName string Username string Password string - AuthType gittypes.GitCredentialAuthType // TLSSkipVerify skips SSL verification when cloning the Git repository TLSSkipVerify bool `example:"false"` } @@ -43,15 +42,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File cleanUp = true - if err := gitService.CloneRepository( - options.ProjectPath, - options.URL, - options.ReferenceName, - options.Username, - options.Password, - options.AuthType, - options.TLSSkipVerify, - ); err != nil { + if err := gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify); err != nil { cleanUp = false if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil { log.Warn().Err(err).Msg("failed restoring backup folder") diff --git a/api/git/git.go b/api/git/git.go index cf0c9f478..6c2835815 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -7,14 +7,12 @@ import ( "strings" gittypes "github.com/portainer/portainer/api/git/types" - "github.com/rs/zerolog/log" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" "github.com/pkg/errors" @@ -35,7 +33,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e URL: opt.repositoryUrl, Depth: opt.depth, InsecureSkipTLS: opt.tlsSkipVerify, - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), Tags: git.NoTags, } @@ -53,10 +51,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e } if !c.preserveGitDirectory { - err := os.RemoveAll(filepath.Join(dst, ".git")) - if err != nil { - log.Error().Err(err).Msg("failed to remove .git directory") - } + os.RemoveAll(filepath.Join(dst, ".git")) } return nil @@ -69,7 +64,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string }) listOptions := &git.ListOptions{ - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, } @@ -99,23 +94,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName) } -func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod { - if password == "" { - return nil - } - - switch authType { - case gittypes.GitCredentialAuthType_Basic: - return getBasicAuth(username, password) - case gittypes.GitCredentialAuthType_Token: - return getTokenAuth(password) - default: - log.Warn().Msg("unknown git credentials authorization type, defaulting to None") - return nil - } -} - -func getBasicAuth(username, password string) *githttp.BasicAuth { +func getAuth(username, password string) *githttp.BasicAuth { if password != "" { if username == "" { username = "token" @@ -129,15 +108,6 @@ func getBasicAuth(username, password string) *githttp.BasicAuth { return nil } -func getTokenAuth(token string) *githttp.TokenAuth { - if token != "" { - return &githttp.TokenAuth{ - Token: token, - } - } - return nil -} - func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) { rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: "origin", @@ -145,7 +115,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err }) listOptions := &git.ListOptions{ - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, } @@ -173,7 +143,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e Depth: 1, SingleBranch: true, ReferenceName: plumbing.ReferenceName(opt.referenceName), - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, Tags: git.NoTags, } diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index 6cb10253a..add10afd6 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -2,8 +2,6 @@ package git import ( "context" - "net/http" - "net/http/httptest" "path/filepath" "testing" "time" @@ -26,15 +24,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) { dst := t.TempDir() repositoryUrl := privateGitRepoURL - err := service.CloneRepository( - dst, - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken, false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } @@ -47,14 +37,7 @@ func TestService_LatestCommitID_GitHub(t *testing.T) { service := newService(context.TODO(), 0, 0) repositoryUrl := privateGitRepoURL - id, err := service.LatestCommitID( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - ) + id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken, false) assert.NoError(t, err) assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") } @@ -67,7 +50,7 @@ func TestService_ListRefs_GitHub(t *testing.T) { service := newService(context.TODO(), 0, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) } @@ -80,8 +63,8 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) { service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) repositoryUrl := privateGitRepoURL - go service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + go service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListRefs(repositoryUrl, username, accessToken, false, false) time.Sleep(2 * time.Second) } @@ -219,17 +202,7 @@ func TestService_ListFiles_GitHub(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, err := service.ListFiles( - tt.args.repositoryUrl, - tt.args.referenceName, - tt.args.username, - tt.args.password, - gittypes.GitCredentialAuthType_Basic, - false, - false, - tt.extensions, - false, - ) + paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) if tt.expect.shouldFail { assert.Error(t, err) if tt.expect.err != nil { @@ -253,28 +226,8 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) { username := getRequiredValue(t, "GITHUB_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) time.Sleep(2 * time.Second) } @@ -287,18 +240,8 @@ func TestService_purgeCache_Github(t *testing.T) { username := getRequiredValue(t, "GITHUB_USERNAME") service := NewService(context.TODO()) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len()) @@ -318,18 +261,8 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) { // 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result service := newService(context.TODO(), 2, 40*timeout) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len()) @@ -360,12 +293,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) { service := newService(context.TODO(), 2, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) assert.Equal(t, 1, service.repoRefCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) } @@ -378,46 +311,26 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) { service := newService(context.TODO(), 2, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) assert.Equal(t, 1, service.repoRefCache.Len()) - files, err := service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 1, service.repoFileCache.Len()) - files, err = service.ListFiles( - repositoryUrl, - "refs/heads/test", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err = service.ListFiles(repositoryUrl, "refs/heads/test", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 2, service.repoFileCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) // The relevant file caches should be removed too @@ -431,72 +344,12 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) { accessToken := getRequiredValue(t, "GITHUB_PAT") username := getRequiredValue(t, "GITHUB_USERNAME") repositoryUrl := privateGitRepoURL - files, err := service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 1, service.repoFileCache.Len()) - _, err = service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - "fake-token", - gittypes.GitCredentialAuthType_Basic, - false, - true, - []string{}, - false, - ) + _, err = service.ListFiles(repositoryUrl, "refs/heads/main", username, "fake-token", false, true, []string{}, false) assert.Error(t, err) assert.Equal(t, 0, service.repoFileCache.Len()) } - -func TestService_CloneRepository_TokenAuth(t *testing.T) { - ensureIntegrationTest(t) - - service := newService(context.TODO(), 2, 0) - var requests []*http.Request - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests = append(requests, r) - })) - accessToken := "test_access_token" - username := "test_username" - repositoryUrl := testServer.URL - - // Since we aren't hitting a real git server we ignore the error - _ = service.CloneRepository( - "test_dir", - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Token, - false, - ) - - testServer.Close() - - if len(requests) != 1 { - t.Fatalf("expected 1 request sent but got %d", len(requests)) - } - - gotAuthHeader := requests[0].Header.Get("Authorization") - if gotAuthHeader == "" { - t.Fatal("no Authorization header in git request") - } - - expectedAuthHeader := "Bearer test_access_token" - if gotAuthHeader != expectedAuthHeader { - t.Fatalf("expected Authorization header %q but got %q", expectedAuthHeader, gotAuthHeader) - } -} diff --git a/api/git/git_test.go b/api/git/git_test.go index fc0db196d..81efa2688 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -38,7 +38,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) { dir := t.TempDir() t.Logf("Cloning into %s", dir) - err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") } @@ -50,7 +50,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) { dir := t.TempDir() t.Logf("Cloning into %s", dir) - err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.NoDirExists(t, filepath.Join(dir, ".git")) } @@ -84,7 +84,7 @@ func Test_latestCommitID(t *testing.T) { repositoryURL := setup(t) referenceName := "refs/heads/main" - id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id) @@ -95,7 +95,7 @@ func Test_ListRefs(t *testing.T) { repositoryURL := setup(t) - fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false) + fs, err := service.ListRefs(repositoryURL, "", "", false, false) assert.NoError(t, err) assert.Equal(t, []string{"refs/heads/main"}, fs) @@ -107,17 +107,7 @@ func Test_ListFiles(t *testing.T) { repositoryURL := setup(t) referenceName := "refs/heads/main" - fs, err := service.ListFiles( - repositoryURL, - referenceName, - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{".yml"}, - false, - ) + fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false) assert.NoError(t, err) assert.Equal(t, []string{"docker-compose.yml"}, fs) @@ -265,7 +255,7 @@ func Test_listFilesPrivateRepository(t *testing.T) { name: "list tree with real repository and head ref but no credential", args: fetchOption{ baseOption: baseOption{ - repositoryUrl: privateGitRepoURL, + repositoryUrl: privateGitRepoURL + "fake", username: "", password: "", }, diff --git a/api/git/service.go b/api/git/service.go index 834e0c827..3e995eccd 100644 --- a/api/git/service.go +++ b/api/git/service.go @@ -8,7 +8,6 @@ import ( "time" lru "github.com/hashicorp/golang-lru" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/rs/zerolog/log" "golang.org/x/sync/singleflight" ) @@ -23,7 +22,6 @@ type baseOption struct { repositoryUrl string username string password string - authType gittypes.GitCredentialAuthType tlsSkipVerify bool } @@ -125,22 +123,13 @@ func (service *Service) timerHasStopped() bool { // CloneRepository clones a git repository using the specified URL in the specified // destination folder. -func (service *Service) CloneRepository( - destination, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error { options := cloneOption{ fetchOption: fetchOption{ baseOption: baseOption{ repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, @@ -166,20 +155,12 @@ func (service *Service) cloneRepository(destination string, options cloneOption) } // LatestCommitID returns SHA1 of the latest commit of the specified reference -func (service *Service) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { options := fetchOption{ baseOption: baseOption{ repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, @@ -189,14 +170,7 @@ func (service *Service) LatestCommitID( } // ListRefs will list target repository's references without cloning the repository -func (service *Service) ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { +func (service *Service) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify)) if service.cacheEnabled && hardRefresh { // Should remove the cache explicitly, so that the following normal list can show the correct result @@ -222,7 +196,6 @@ func (service *Service) ListRefs( repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, } @@ -242,62 +215,18 @@ var singleflightGroup = &singleflight.Group{} // ListFiles will list all the files of the target repository with specific extensions. // If extension is not provided, it will list all the files under the target repository -func (service *Service) ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includedExts []string, - tlsSkipVerify bool, -) ([]string, error) { - repoKey := generateCacheKey( - repositoryURL, - referenceName, - username, - password, - strconv.FormatBool(tlsSkipVerify), - strconv.Itoa(int(authType)), - strconv.FormatBool(dirOnly), - ) +func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) { + repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) { - return service.listFiles( - repositoryURL, - referenceName, - username, - password, - authType, - dirOnly, - hardRefresh, - tlsSkipVerify, - ) + return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify) }) return filterFiles(fs.([]string), includedExts), err } -func (service *Service) listFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { - repoKey := generateCacheKey( - repositoryURL, - referenceName, - username, - password, - strconv.FormatBool(tlsSkipVerify), - strconv.Itoa(int(authType)), - strconv.FormatBool(dirOnly), - ) +func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { + repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) if service.cacheEnabled && hardRefresh { // Should remove the cache explicitly, so that the following normal list can show the correct result @@ -318,7 +247,6 @@ func (service *Service) listFiles( repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, diff --git a/api/git/types/types.go b/api/git/types/types.go index cb9d7cf03..12d95e093 100644 --- a/api/git/types/types.go +++ b/api/git/types/types.go @@ -1,21 +1,12 @@ package gittypes -import ( - "errors" -) +import "errors" var ( ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct") ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct") ) -type GitCredentialAuthType int - -const ( - GitCredentialAuthType_Basic GitCredentialAuthType = iota - GitCredentialAuthType_Token -) - // RepoConfig represents a configuration for a repo type RepoConfig struct { // The repo url @@ -33,11 +24,10 @@ type RepoConfig struct { } type GitAuthentication struct { - Username string - Password string - AuthorizationType GitCredentialAuthType + Username string + Password string // Git credentials identifier when the value is not 0 - // When the value is 0, Username, Password, and Authtype are set without using saved credential + // When the value is 0, Username and Password are set without using saved credential // This is introduced since 2.15.0 GitCredentialID int `example:"0"` } diff --git a/api/git/update/update.go b/api/git/update/update.go index 780d6e046..203e361dd 100644 --- a/api/git/update/update.go +++ b/api/git/update/update.go @@ -29,14 +29,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId) } - newHash, err := gitService.LatestCommitID( - gitConfig.URL, - gitConfig.ReferenceName, - username, - password, - gittypes.GitCredentialAuthType_Basic, - gitConfig.TLSSkipVerify, - ) + newHash, err := gitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, username, password, gitConfig.TLSSkipVerify) if err != nil { return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) } @@ -69,7 +62,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g cloneParams.auth = &gitAuth{ username: username, password: password, - authType: gitConfig.Authentication.AuthorizationType, } } @@ -97,31 +89,14 @@ type cloneRepositoryParameters struct { } type gitAuth struct { - authType gittypes.GitCredentialAuthType username string password string } func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error { if cloneParams.auth != nil { - return gitService.CloneRepository( - cloneParams.toDir, - cloneParams.url, - cloneParams.ref, - cloneParams.auth.username, - cloneParams.auth.password, - cloneParams.auth.authType, - cloneParams.tlsSkipVerify, - ) + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password, cloneParams.tlsSkipVerify) } - return gitService.CloneRepository( - cloneParams.toDir, - cloneParams.url, - cloneParams.ref, - "", - "", - gittypes.GitCredentialAuthType_Basic, - cloneParams.tlsSkipVerify, - ) + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "", cloneParams.tlsSkipVerify) } diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go index 5843c1bdb..b27b78878 100644 --- a/api/hostmanagement/openamt/openamt.go +++ b/api/hostmanagement/openamt/openamt.go @@ -32,9 +32,9 @@ type Service struct { } // NewService initializes a new service. -func NewService(insecureSkipVerify bool) *Service { +func NewService() *Service { tlsConfig := crypto.CreateTLSConfiguration() - tlsConfig.InsecureSkipVerify = insecureSkipVerify + tlsConfig.InsecureSkipVerify = true return &Service{ httpsClient: &http.Client{ diff --git a/api/http/csrf/csrf.go b/api/http/csrf/csrf.go index 6205c9290..857d72c8b 100644 --- a/api/http/csrf/csrf.go +++ b/api/http/csrf/csrf.go @@ -2,7 +2,6 @@ package csrf import ( "crypto/rand" - "errors" "fmt" "net/http" "os" @@ -10,8 +9,7 @@ import ( "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" - gcsrf "github.com/gorilla/csrf" - "github.com/rs/zerolog/log" + gorillacsrf "github.com/gorilla/csrf" "github.com/urfave/negroni" ) @@ -21,7 +19,7 @@ func SkipCSRFToken(w http.ResponseWriter) { w.Header().Set(csrfSkipHeader, "1") } -func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) { +func WithProtect(handler http.Handler) (http.Handler, error) { // IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck) // DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml isDockerDesktopExtension := false @@ -36,12 +34,10 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e return nil, fmt.Errorf("failed to generate CSRF token: %w", err) } - handler = gcsrf.Protect( + handler = gorillacsrf.Protect( token, - gcsrf.Path("/"), - gcsrf.Secure(false), - gcsrf.TrustedOrigins(trustedOrigins), - gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)), + gorillacsrf.Path("/"), + gorillacsrf.Secure(false), )(handler) return withSkipCSRF(handler, isDockerDesktopExtension), nil @@ -59,7 +55,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler { } if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 { - sw.Header().Set("X-CSRF-Token", gcsrf.Token(r)) + sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r)) } }) @@ -77,33 +73,9 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand } if skip { - r = gcsrf.UnsafeSkipCheck(r) + r = gorillacsrf.UnsafeSkipCheck(r) } handler.ServeHTTP(w, r) }) } - -func withErrorHandler(trustedOrigins []string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := gcsrf.FailureReason(r) - - if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) { - log.Error().Err(err). - Str("request_url", r.URL.String()). - Str("host", r.Host). - Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")). - Str("forwarded", r.Header.Get("Forwarded")). - Str("origin", r.Header.Get("Origin")). - Str("referer", r.Header.Get("Referer")). - Strs("trusted_origins", trustedOrigins). - Msg("Failed to validate Origin or Referer") - } - - http.Error( - w, - http.StatusText(http.StatusForbidden)+" - "+err.Error(), - http.StatusForbidden, - ) - }) -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 4df31c92c..989949daa 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -2,7 +2,6 @@ package auth import ( "net/http" - "strconv" "strings" portainer "github.com/portainer/portainer/api" @@ -83,11 +82,6 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h } } - // Clear any existing user caches - if user != nil { - handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID))) - } - if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal { return handler.authenticateInternal(rw, user, payload.Password) } diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 035ceabf8..3b7210fbf 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -8,7 +8,6 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/gorilla/mux" @@ -24,18 +23,16 @@ type Handler struct { OAuthService portainer.OAuthService ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager - KubernetesClientFactory *cli.ClientFactory passwordStrengthChecker security.PasswordStrengthChecker bouncer security.BouncerService } // NewHandler creates a handler to manage authentication operations. -func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler { +func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler { h := &Handler{ Router: mux.NewRouter(), passwordStrengthChecker: passwordStrengthChecker, bouncer: bouncer, - KubernetesClientFactory: kubernetesClientFactory, } h.Handle("/auth/oauth/validate", diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index 73288565d..977fafa69 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -2,7 +2,6 @@ package auth import ( "net/http" - "strconv" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/logoutcontext" @@ -24,7 +23,6 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro if tokenData != nil { handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) - handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID))) logoutcontext.Cancel(tokenData.Token) } diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go index b63db356d..60ed1666f 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -33,28 +33,13 @@ type TestGitService struct { targetFilePath string } -func (g *TestGitService) CloneRepository( - destination string, - repositoryURL, - referenceName string, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *TestGitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string, tlsSkipVerify bool) error { time.Sleep(100 * time.Millisecond) return createTestFile(g.targetFilePath) } -func (g *TestGitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *TestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return "", nil } @@ -71,26 +56,11 @@ type InvalidTestGitService struct { targetFilePath string } -func (g *InvalidTestGitService) CloneRepository( - dest, - repoUrl, - refName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error { return errors.New("simulate network error") } -func (g *InvalidTestGitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return "", nil } diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index c96d61523..581b219ae 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = filterByType(customTemplates, templateTypes) if edge != nil { - customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { return customTemplate.EdgeTemplate == *edge }) } diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index f12eeb2e1..f14d228f3 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -37,16 +37,14 @@ type customTemplateUpdatePayload struct { RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` // Reference name of a Git repository hosting the Stack file RepositoryReferenceName string `example:"refs/heads/master"` - // Use authentication to clone the Git repository + // Use basic authentication to clone the Git repository RepositoryAuthentication bool `example:"true"` // Username used in basic authentication. Required when RepositoryAuthentication is true - // and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token + // and RepositoryGitCredentialID is 0 RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication or token used in token authentication. - // Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0 + // Password used in basic authentication. Required when RepositoryAuthentication is true + // and RepositoryGitCredentialID is 0 RepositoryPassword string `example:"myGitPassword"` - // RepositoryAuthorizationType is the authorization type to use - RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"` // GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication // is true and RepositoryUsername/RepositoryPassword are not provided RepositoryGitCredentialID int `example:"0"` @@ -184,15 +182,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if payload.RepositoryAuthentication { repositoryUsername = payload.RepositoryUsername repositoryPassword = payload.RepositoryPassword - repositoryAuthType = payload.RepositoryAuthorizationType gitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, } } @@ -202,7 +197,6 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ ReferenceName: gitConfig.ReferenceName, Username: repositoryUsername, Password: repositoryPassword, - AuthType: repositoryAuthType, TLSSkipVerify: gitConfig.TLSSkipVerify, }) if err != nil { @@ -211,14 +205,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ defer cleanBackup() - commitHash, err := handler.GitService.LatestCommitID( - gitConfig.URL, - gitConfig.ReferenceName, - repositoryUsername, - repositoryPassword, - repositoryAuthType, - gitConfig.TLSSkipVerify, - ) + commitHash, err := handler.GitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, repositoryUsername, repositoryPassword, gitConfig.TLSSkipVerify) if err != nil { return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err)) } diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go index b26e94d0c..d03618c56 100644 --- a/api/http/handler/edgegroups/associated_endpoints.go +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -4,7 +4,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" ) type endpointSetType map[portainer.EndpointID]bool @@ -50,29 +49,22 @@ func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, p return results, nil } -func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) { - var innerErr error - +func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs []portainer.EndpointID) ([]portainer.EndpointID, error) { results := []portainer.EndpointID{} - - endpointIDs.Iterate(func(endpointID portainer.EndpointID) bool { + for _, endpointID := range endpointIDs { endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { - innerErr = err - - return false + return nil, err } if !endpoint.UserTrusted { - return true + continue } results = append(results, endpoint.ID) + } - return true - }) - - return results, innerErr + return results, nil } func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType { diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index c074bffde..3988160f0 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -7,7 +7,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) @@ -53,7 +52,6 @@ func calculateEndpointsOrTags(tx dataservices.DataStoreTx, edgeGroup *portainer. } edgeGroup.Endpoints = endpointIDs - edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) return nil } @@ -96,7 +94,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) Dynamic: payload.Dynamic, TagIDs: []portainer.TagID{}, Endpoints: []portainer.EndpointID{}, - EndpointIDs: roar.Roar[portainer.EndpointID]{}, PartialMatch: payload.PartialMatch, } @@ -111,5 +108,5 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return nil }) - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } diff --git a/api/http/handler/edgegroups/edgegroup_create_test.go b/api/http/handler/edgegroups/edgegroup_create_test.go deleted file mode 100644 index e7710432f..000000000 --- a/api/http/handler/edgegroups/edgegroup_create_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupCreateHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodPost, - "/edge_groups", - strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`), - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index 76780ec1d..c17ac6b7c 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -5,7 +5,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) @@ -34,9 +33,7 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) return err }) - edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() - - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { @@ -53,7 +50,7 @@ func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*porta return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err) } - edgeGroup.EndpointIDs = roar.FromSlice(endpoints) + edgeGroup.Endpoints = endpoints } return edgeGroup, err diff --git a/api/http/handler/edgegroups/edgegroup_inspect_test.go b/api/http/handler/edgegroups/edgegroup_inspect_test.go deleted file mode 100644 index 5af282372..000000000 --- a/api/http/handler/edgegroups/edgegroup_inspect_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupInspectHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups/1", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - assert.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} - -func TestEmptyEdgeGroupInspectHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.Roar[portainer.EndpointID]{}, - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups/1", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - // Make sure the frontend does not get a null value but a [] instead - require.NotNil(t, responseGroup.Endpoints) - require.Len(t, responseGroup.Endpoints, 0) -} - -func TestDynamicEdgeGroupInspectHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - err = store.Tag().Create(&portainer.Tag{ - ID: 1, - Name: "Test Tag", - Endpoints: map[portainer.EndpointID]bool{ - 1: true, - 2: true, - 3: true, - }, - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - TagIDs: []portainer.TagID{1}, - UserTrusted: true, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - Dynamic: true, - TagIDs: []portainer.TagID{1}, - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups/1", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index 87de867eb..bc67176fd 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -7,17 +7,11 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" ) -type shadowedEdgeGroup struct { - portainer.EdgeGroup - EndpointIds int `json:"EndpointIds,omitempty"` // Shadow to avoid exposing in the API -} - type decoratedEdgeGroup struct { - shadowedEdgeGroup + portainer.EdgeGroup HasEdgeStack bool `json:"HasEdgeStack"` HasEdgeJob bool `json:"HasEdgeJob"` EndpointTypes []portainer.EndpointType @@ -82,8 +76,8 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) } edgeGroup := decoratedEdgeGroup{ - shadowedEdgeGroup: shadowedEdgeGroup{EdgeGroup: orgEdgeGroup}, - EndpointTypes: []portainer.EndpointType{}, + EdgeGroup: orgEdgeGroup, + EndpointTypes: []portainer.EndpointType{}, } if edgeGroup.Dynamic { endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) @@ -94,16 +88,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) edgeGroup.Endpoints = endpointIDs edgeGroup.TrustedEndpoints = endpointIDs } else { - trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.EndpointIDs) + trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.Endpoints) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err) } - edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() edgeGroup.TrustedEndpoints = trustedEndpoints } - endpointTypes, err := getEndpointTypes(tx, edgeGroup.EndpointIDs) + endpointTypes, err := getEndpointTypes(tx, edgeGroup.Endpoints) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err) } @@ -118,26 +111,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) return decoratedEdgeGroups, nil } -func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds roar.Roar[portainer.EndpointID]) ([]portainer.EndpointType, error) { - var innerErr error - +func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) { typeSet := map[portainer.EndpointType]bool{} - - endpointIds.Iterate(func(endpointID portainer.EndpointID) bool { + for _, endpointID := range endpointIds { endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { - innerErr = fmt.Errorf("failed fetching environment: %w", err) - - return false + return nil, fmt.Errorf("failed fetching environment: %w", err) } typeSet[endpoint.Type] = true - - return true - }) - - if innerErr != nil { - return nil, innerErr } endpointTypes := make([]portainer.EndpointType, 0, len(typeSet)) diff --git a/api/http/handler/edgegroups/edgegroup_list_test.go b/api/http/handler/edgegroups/edgegroup_list_test.go index bf084c377..b77b2966e 100644 --- a/api/http/handler/edgegroups/edgegroup_list_test.go +++ b/api/http/handler/edgegroups/edgegroup_list_test.go @@ -1,19 +1,11 @@ package edgegroups import ( - "net/http" - "net/http/httptest" - "strconv" "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_getEndpointTypes(t *testing.T) { @@ -46,7 +38,7 @@ func Test_getEndpointTypes(t *testing.T) { } for _, test := range tests { - ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds)) + ans, err := getEndpointTypes(datastore, test.endpointIds) assert.NoError(t, err, "getEndpointTypes shouldn't fail") assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) @@ -56,61 +48,6 @@ func Test_getEndpointTypes(t *testing.T) { func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) { datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) - _, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1})) + _, err := getEndpointTypes(datastore, []portainer.EndpointID{1}) assert.Error(t, err, "getEndpointTypes should fail") } - -func TestEdgeGroupListHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroups []decoratedEdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroups) - require.NoError(t, err) - - require.Len(t, responseGroups, 1) - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints) - require.Len(t, responseGroups[0].TrustedEndpoints, 0) -} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index 270bd10df..7831b634e 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -158,12 +158,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) return nil }) - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { return err } @@ -179,6 +179,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi edgeStackSet[edgeStackID] = true } + if relation == nil { + relation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } relation.EdgeStacks = edgeStackSet return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) diff --git a/api/http/handler/edgegroups/edgegroup_update_test.go b/api/http/handler/edgegroups/edgegroup_update_test.go deleted file mode 100644 index dbecbdfcf..000000000 --- a/api/http/handler/edgegroups/edgegroup_update_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupUpdateHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodPut, - "/edge_groups/1", - strings.NewReader(`{"Endpoints": [1, 2, 3]}`), - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index a8775495d..d20e5b5c2 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -33,8 +33,6 @@ type edgeStackFromGitRepositoryPayload struct { RepositoryUsername string `example:"myGitUsername"` // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` - // RepositoryAuthorizationType is the authorization type to use - RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"` // Path to the Stack file inside the Git repository FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` // List of identifiers of EdgeGroups @@ -127,9 +125,8 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat if payload.RepositoryAuthentication { repoConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, } } @@ -148,22 +145,12 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { repositoryUsername = repositoryConfig.Authentication.Username repositoryPassword = repositoryConfig.Authentication.Password - repositoryAuthType = repositoryConfig.Authentication.AuthorizationType } - if err := handler.GitService.CloneRepository( - projectPath, - repositoryConfig.URL, - repositoryConfig.ReferenceName, - repositoryUsername, - repositoryPassword, - repositoryAuthType, - repositoryConfig.TLSSkipVerify, - ); err != nil { + if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil { return "", "", "", err } diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go index 70252c25d..486cc09d0 100644 --- a/api/http/handler/edgestacks/edgestack_create_test.go +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -8,10 +8,9 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/roar" + "github.com/stretchr/testify/require" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" ) // Create @@ -25,7 +24,7 @@ func TestCreateAndInspect(t *testing.T) { Name: "EdgeGroup 1", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}), + Endpoints: []portainer.EndpointID{endpoint.ID}, PartialMatch: false, } diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index 1ea991c4b..b0df238c3 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -3,39 +3,10 @@ package edgestacks import ( "net/http" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/slicesx" httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" ) -type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int - -type SummarizedStatus string - -const ( - sumStatusUnavailable SummarizedStatus = "Unavailable" - sumStatusDeploying SummarizedStatus = "Deploying" - sumStatusFailed SummarizedStatus = "Failed" - sumStatusPaused SummarizedStatus = "Paused" - sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning" - sumStatusCompleted SummarizedStatus = "Completed" - sumStatusRunning SummarizedStatus = "Running" -) - -type edgeStackStatusSummary struct { - AggregatedStatus aggregatedStatusesMap - Status SummarizedStatus - Reason string -} - -type edgeStackListResponseItem struct { - portainer.EdgeStack - StatusSummary edgeStackStatusSummary -} - // @id EdgeStackList // @summary Fetches the list of EdgeStacks // @description **Access policy**: administrator @@ -43,122 +14,22 @@ type edgeStackListResponseItem struct { // @security ApiKeyAuth // @security jwt // @produce json -// @param summarizeStatuses query boolean false "will summarize the statuses" // @success 200 {array} portainer.EdgeStack // @failure 500 // @failure 400 // @failure 503 "Edge compute features are disabled" // @router /edge_stacks [get] func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true) - edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } - res := make([]edgeStackListResponseItem, len(edgeStacks)) - for i := range edgeStacks { - res[i].EdgeStack = edgeStacks[i] - - if summarizeStatuses { - if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - } else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil { + if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil { return handlerDBErr(err, "Unable to retrieve edge stack status from the database") } } - return response.JSON(w, res) -} - -func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error { - statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) - if err != nil { - return err - } - - aggregated := make(aggregatedStatusesMap) - - for _, envStatus := range statuses { - for _, status := range envStatus.Status { - aggregated[status.Type]++ - } - } - - status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments) - - edgeStack.StatusSummary = edgeStackStatusSummary{ - AggregatedStatus: aggregated, - Status: status, - Reason: reason, - } - - edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} - - return nil -} - -func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) { - if numDeployments == 0 { - return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group" - } - - allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus { - return x.Status - }) - - lastStatuses := slicesx.Map( - slicesx.Filter( - statuses, - func(s portainer.EdgeStackStatusForEnv) bool { - return len(s.Status) > 0 - }, - ), - func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus { - return x.Status[len(x.Status)-1] - }, - ) - - if len(lastStatuses) == 0 { - return sumStatusDeploying, "" - } - - if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusError - }); allFailed { - return sumStatusFailed, "" - } - - if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusPausedDeploying - }); hasPaused { - return sumStatusPaused, "" - } - - if len(lastStatuses) < numDeployments { - return sumStatusDeploying, "" - } - - hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying }) - hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning }) - hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError }) - - if hasRunning && hasFailed && !hasDeploying { - return sumStatusPartiallyRunning, "" - } - - if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted { - return sumStatusCompleted, "" - } - - if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusRunning - }); allRunning { - return sumStatusRunning, "" - } - - return sumStatusDeploying, "" + return response.JSON(w, edgeStacks) } diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index 0ff6a9eff..4f99e7ab3 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -133,9 +133,7 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack } environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID) - if err != nil && !tx.IsErrObjectNotFound(err) { - return err - } else if tx.IsErrObjectNotFound(err) { + if err != nil { environmentStatus = &portainer.EdgeStackStatusForEnv{ EndpointID: payload.EndpointID, Status: []portainer.EdgeStackDeploymentStatus{}, diff --git a/api/http/handler/edgestacks/edgestack_test.go b/api/http/handler/edgestacks/edgestack_test.go index 38fd4be55..91600117b 100644 --- a/api/http/handler/edgestacks/edgestack_test.go +++ b/api/http/handler/edgestacks/edgestack_test.go @@ -15,7 +15,6 @@ import ( "github.com/portainer/portainer/api/internal/edge/edgestacks" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" - "github.com/portainer/portainer/api/roar" "github.com/pkg/errors" "github.com/stretchr/testify/require" @@ -104,7 +103,7 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port Name: "EdgeGroup 1", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}), + Endpoints: []portainer.EndpointID{endpointID}, PartialMatch: false, } diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go index 8040af329..68baa4129 100644 --- a/api/http/handler/edgestacks/edgestack_update_test.go +++ b/api/http/handler/edgestacks/edgestack_update_test.go @@ -9,10 +9,9 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/roar" + "github.com/stretchr/testify/require" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" ) // Update @@ -44,7 +43,7 @@ func TestUpdateAndInspect(t *testing.T) { Name: "EdgeGroup 2", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}), + Endpoints: []portainer.EndpointID{newEndpoint.ID}, PartialMatch: false, } @@ -113,7 +112,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { Name: "EdgeGroup 2", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}), + Endpoints: []portainer.EndpointID{8889}, PartialMatch: false, } diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go index 9bd341561..4d6368493 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go @@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) { relation, err := tx.EndpointRelation().EndpointRelation(endpointID) if err != nil { + if tx.IsErrObjectNotFound(err) { + return nil, nil + } return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err) } diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go index 526fc58de..ca9b12723 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go @@ -16,7 +16,6 @@ import ( "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/jwt" - "github.com/portainer/portainer/api/roar" "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" @@ -367,8 +366,8 @@ func TestEdgeJobsResponse(t *testing.T) { unrelatedEndpoint := localCreateEndpoint(80, nil) staticEdgeGroup := portainer.EdgeGroup{ - ID: 1, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointFromStaticEdgeGroup.ID}), + ID: 1, + Endpoints: []portainer.EndpointID{endpointFromStaticEdgeGroup.ID}, } err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup) require.NoError(t, err) diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go index 8b420f2a6..b34032d9e 100644 --- a/api/http/handler/endpointgroups/endpoints.go +++ b/api/http/handler/endpointgroups/endpoints.go @@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end } endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !tx.IsErrObjectNotFound(err) { return err } + if endpointRelation == nil { + endpointRelation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } + edgeGroups, err := tx.EdgeGroup().ReadAll() if err != nil { return err diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 3cfe934bc..1c6415023 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -563,10 +563,6 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data return err } - if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil { - return err - } - for _, tagID := range endpoint.TagIDs { if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) { tag.Endpoints[endpoint.ID] = true diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index a9b4ae5dc..f26b9dd13 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -3,6 +3,7 @@ package endpoints import ( "errors" "net/http" + "slices" "strconv" portainer "github.com/portainer/portainer/api" @@ -199,7 +200,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p } for _, edgeGroup := range edgeGroups { - edgeGroup.EndpointIDs.Remove(endpoint.ID) + edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(e portainer.EndpointID) bool { + return e == endpoint.ID + }) if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil { log.Warn().Err(err).Msg("Unable to update edge group") diff --git a/api/http/handler/endpoints/endpoint_delete_test.go b/api/http/handler/endpoints/endpoint_delete_test.go index 73b2b878b..309b45ffe 100644 --- a/api/http/handler/endpoints/endpoint_delete_test.go +++ b/api/http/handler/endpoints/endpoint_delete_test.go @@ -11,7 +11,6 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" ) func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { @@ -22,7 +21,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { handler := NewHandler(testhelpers.NewTestRequestBouncer()) handler.DataStore = store handler.ProxyManager = proxy.NewManager(nil) - handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil) // Create all the environments and add them to the same edge group @@ -43,9 +42,9 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { } if err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "edgegroup-1", - EndpointIDs: roar.FromSlice(endpointIDs), + ID: 1, + Name: "edgegroup-1", + Endpoints: endpointIDs, }); err != nil { t.Fatal("could not create edge group:", err) } @@ -79,7 +78,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { t.Fatal("could not retrieve the edge group:", err) } - if edgeGroup.EndpointIDs.Len() > 0 { + if len(edgeGroup.Endpoints) > 0 { t.Fatal("the edge group is not consistent") } } diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 43b14ad6a..86f1b1d3c 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -95,11 +95,12 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht return httperror.BadRequest("Invalid query parameters", err) } - filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext) + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings) if err != nil { return httperror.InternalServerError("Unable to filter endpoints", err) } - filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext) sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc") diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go index 5bc4a930d..e81bc34a9 100644 --- a/api/http/handler/endpoints/endpoint_registries_list.go +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) } - registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships) + registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships) if handleError != nil { return nil, handleError } @@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return registries, err } -func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { if !endpointutils.IsKubernetesEndpoint(endpoint) { return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil } - return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships) + return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships) } -func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true) isAdmin, err := security.IsAdmin(r) if err != nil { @@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataS return registries, nil } - return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user) + return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user) } func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { @@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc return false } -func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if errors.Is(err, security.ErrAuthorizationRequired) { return nil, httperror.Forbidden("User is not authorized", err) @@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) } - userNamespaces, err := handler.userNamespaces(tx, endpoint, user) + userNamespaces, err := handler.userNamespaces(endpoint, user) if err != nil { return nil, httperror.InternalServerError("unable to retrieve user namespaces", err) } @@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil } -func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { +func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err @@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *po return nil, err } - userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) + userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { return nil, err } diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 961cad147..9b6004d1c 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -11,10 +11,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/handler/edgegroups" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/pkg/errors" @@ -141,14 +140,11 @@ func (handler *Handler) filterEndpointsByQuery( groups []portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, settings *portainer.Settings, - context *security.RestrictedRequestContext, ) ([]portainer.Endpoint, int, error) { totalAvailableEndpoints := len(filteredEndpoints) if len(query.endpointIds) > 0 { - endpointIDs := roar.FromSlice(query.endpointIds) - - filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs) + filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds) } if len(query.excludeIds) > 0 { @@ -185,16 +181,11 @@ func (handler *Handler) filterEndpointsByQuery( } // filter edge environments by trusted/untrusted - // only portainer admins are allowed to see untrusted environments filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { if !endpointutils.IsEdgeEndpoint(&endpoint) { return true } - if query.edgeDeviceUntrusted { - return !endpoint.UserTrusted && context.IsAdmin - } - return endpoint.UserTrusted == !query.edgeDeviceUntrusted }) @@ -277,7 +268,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database") } - envIds := roar.Roar[portainer.EndpointID]{} + envIds := make([]portainer.EndpointID, 0) for _, edgeGroupdId := range stack.EdgeGroups { edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId) if err != nil { @@ -289,37 +280,30 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port if err != nil { return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group") } - edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) + edgeGroup.Endpoints = endpointIDs } - envIds.Union(edgeGroup.EndpointIDs) + envIds = append(envIds, edgeGroup.Endpoints...) } if statusFilter != nil { - var innerErr error - - envIds.Iterate(func(envId portainer.EndpointID) bool { + n := 0 + for _, envId := range envIds { 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 err != nil { + return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId) } - if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) { - envIds.Remove(envId) + if endpointStatusInStackMatchesFilter(edgeStackStatus, envId, *statusFilter) { + envIds[n] = envId + n++ } - - return true - }) - - if innerErr != nil { - return nil, innerErr } + envIds = envIds[:n] } - filteredEndpoints := filteredEndpointsByIds(endpoints, envIds) + uniqueIds := slicesx.Unique(envIds) + filteredEndpoints := filteredEndpointsByIds(endpoints, uniqueIds) return filteredEndpoints, nil } @@ -351,14 +335,16 @@ func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups [] } edgeGroups = edgeGroups[:n] - endpointIDSet := roar.Roar[portainer.EndpointID]{} + endpointIDSet := make(map[portainer.EndpointID]struct{}) for _, edgeGroup := range edgeGroups { - endpointIDSet.Union(edgeGroup.EndpointIDs) + for _, endpointID := range edgeGroup.Endpoints { + endpointIDSet[endpointID] = struct{}{} + } } n = 0 for _, endpoint := range endpoints { - if endpointIDSet.Contains(endpoint.ID) { + if _, exists := endpointIDSet[endpoint.ID]; exists { endpoints[n] = endpoint n++ } @@ -374,11 +360,12 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr } n := 0 - excludeEndpointIDSet := roar.Roar[portainer.EndpointID]{} - + excludeEndpointIDSet := make(map[portainer.EndpointID]struct{}) for _, edgeGroup := range edgeGroups { if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok { - excludeEndpointIDSet.Union(edgeGroup.EndpointIDs) + for _, endpointID := range edgeGroup.Endpoints { + excludeEndpointIDSet[endpointID] = struct{}{} + } } else { edgeGroups[n] = edgeGroup n++ @@ -388,7 +375,7 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr n = 0 for _, endpoint := range endpoints { - if !excludeEndpointIDSet.Contains(endpoint.ID) { + if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok { endpoints[n] = endpoint n++ } @@ -613,10 +600,15 @@ func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer. return len(missingTags) == 0 } -func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids roar.Roar[portainer.EndpointID]) []portainer.Endpoint { +func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint { + idsSet := make(map[portainer.EndpointID]bool, len(ids)) + for _, id := range ids { + idsSet[id] = true + } + n := 0 for _, endpoint := range endpoints { - if ids.Contains(endpoint.ID) { + if idsSet[endpoint.ID] { endpoints[n] = endpoint n++ } diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go index 642448b86..f19d0a276 100644 --- a/api/http/handler/endpoints/filter_test.go +++ b/api/http/handler/endpoints/filter_test.go @@ -6,13 +6,10 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" "github.com/portainer/portainer/api/slicesx" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) type filterTest struct { @@ -177,7 +174,7 @@ func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) { edgeGroups = append(edgeGroups, portainer.EdgeGroup{ ID: portainer.EdgeGroupID(i + 1), Name: "edge-group-" + strconv.Itoa(i+1), - EndpointIDs: roar.FromSlice(endpointIDs), + Endpoints: append([]portainer.EndpointID{}, endpointIDs...), Dynamic: true, TagIDs: []portainer.TagID{1, 2, 3}, PartialMatch: true, @@ -224,11 +221,11 @@ func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) { edgeGroups := []portainer.EdgeGroup{} for i := range 1000 { edgeGroups = append(edgeGroups, portainer.EdgeGroup{ - ID: portainer.EdgeGroupID(i + 1), - Name: "edge-group-" + strconv.Itoa(i+1), - EndpointIDs: roar.FromSlice(endpointIDs), - Dynamic: true, - TagIDs: []portainer.TagID{1}, + ID: portainer.EdgeGroupID(i + 1), + Name: "edge-group-" + strconv.Itoa(i+1), + Endpoints: append([]portainer.EndpointID{}, endpointIDs...), + Dynamic: true, + TagIDs: []portainer.TagID{1}, }) } @@ -266,7 +263,6 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai []portainer.EndpointGroup{}, []portainer.EdgeGroup{}, &portainer.Settings{}, - &security.RestrictedRequestContext{IsAdmin: true}, ) is.NoError(err) @@ -302,127 +298,3 @@ func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) *Handler { return handler } - -func TestFilterEndpointsByEdgeStack(t *testing.T) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - edgeStackId := portainer.EdgeStackID(1) - - err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{ - ID: edgeStackId, - Name: "Test Edge Stack", - EdgeGroups: []portainer.EdgeGroupID{1, 2}, - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(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) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(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) - - edgeGroups, err := store.EdgeGroup().ReadAll() - require.NoError(t, err) - - es, egs := filterEndpointsByEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1, 2}) - 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 - - require.Len(t, egs, 2) - require.Equal(t, egs[0].ID, portainer.EdgeGroupID(1)) - require.Equal(t, egs[1].ID, portainer.EdgeGroupID(2)) -} - -func TestFilterEndpointsByExcludeEdgeGroupIDs(t *testing.T) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(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) - - edgeGroups, err := store.EdgeGroup().ReadAll() - require.NoError(t, err) - - es, egs := filterEndpointsByExcludeEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1}) - require.NoError(t, err) - - require.Len(t, es, 3) - require.Equal(t, es, []portainer.Endpoint{ - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - }) - - require.Len(t, egs, 1) - require.Equal(t, egs[0].ID, portainer.EdgeGroupID(2)) -} diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go index c487519f0..1390c9fd4 100644 --- a/api/http/handler/endpoints/update_edge_relations.go +++ b/api/http/handler/endpoints/update_edge_relations.go @@ -17,7 +17,17 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { - return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") + if !tx.IsErrObjectNotFound(err) { + return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") + } + + relation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + if err := tx.EndpointRelation().Create(relation); err != nil { + return errors.WithMessage(err, "Unable to create environment relation inside the database") + } } endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) diff --git a/api/http/handler/endpoints/utils_update_edge_groups.go b/api/http/handler/endpoints/utils_update_edge_groups.go index 6207acbc5..bd9c413d7 100644 --- a/api/http/handler/endpoints/utils_update_edge_groups.go +++ b/api/http/handler/endpoints/utils_update_edge_groups.go @@ -1,11 +1,12 @@ package endpoints import ( + "slices" + + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/set" - - "github.com/pkg/errors" ) func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) { @@ -18,8 +19,10 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{} for _, edgeGroup := range edgeGroups { - if edgeGroup.EndpointIDs.Contains(environmentID) { - environmentEdgeGroupsSet[edgeGroup.ID] = true + for _, eID := range edgeGroup.Endpoints { + if eID == environmentID { + environmentEdgeGroupsSet[edgeGroup.ID] = true + } } } @@ -49,16 +52,20 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po } removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet) - if err := updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { - edgeGroup.EndpointIDs.Remove(environmentID) - }); err != nil { + err = updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(eID portainer.EndpointID) bool { + return eID == environmentID + }) + }) + if err != nil { return false, err } addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet) - if err := updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { - edgeGroup.EndpointIDs.Add(environmentID) - }); err != nil { + err = updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.Endpoints = append(edgeGroup.Endpoints, environmentID) + }) + if err != nil { return false, err } diff --git a/api/http/handler/endpoints/utils_update_edge_groups_test.go b/api/http/handler/endpoints/utils_update_edge_groups_test.go index a57651fae..e89d501fb 100644 --- a/api/http/handler/endpoints/utils_update_edge_groups_test.go +++ b/api/http/handler/endpoints/utils_update_edge_groups_test.go @@ -6,7 +6,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" - "github.com/stretchr/testify/assert" ) @@ -15,9 +14,10 @@ func Test_updateEdgeGroups(t *testing.T) { groups := make([]portainer.EdgeGroup, len(names)) for index, name := range names { group := &portainer.EdgeGroup{ - Name: name, - Dynamic: false, - TagIDs: make([]portainer.TagID, 0), + Name: name, + Dynamic: false, + TagIDs: make([]portainer.TagID, 0), + Endpoints: make([]portainer.EndpointID, 0), } if err := store.EdgeGroup().Create(group); err != nil { @@ -35,8 +35,13 @@ func Test_updateEdgeGroups(t *testing.T) { group, err := store.EdgeGroup().Read(groupID) is.NoError(err) - is.True(group.EndpointIDs.Contains(endpointID), - "expected endpoint to be in group") + for _, endpoint := range group.Endpoints { + if endpoint == endpointID { + return + } + } + + is.Fail("expected endpoint to be in group") } } @@ -76,7 +81,7 @@ func Test_updateEdgeGroups(t *testing.T) { endpointGroups := groupsByName(groups, testCase.endpointGroupNames) for _, group := range endpointGroups { - group.EndpointIDs.Add(testCase.endpoint.ID) + group.Endpoints = append(group.Endpoints, testCase.endpoint.ID) err = store.EdgeGroup().Update(group.ID, &group) is.NoError(err) diff --git a/api/http/handler/endpoints/utils_update_tags_test.go b/api/http/handler/endpoints/utils_update_tags_test.go index 527f963a4..ee42e4e10 100644 --- a/api/http/handler/endpoints/utils_update_tags_test.go +++ b/api/http/handler/endpoints/utils_update_tags_test.go @@ -10,6 +10,7 @@ import ( ) func Test_updateTags(t *testing.T) { + createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) { tags := make([]portainer.Tag, len(tagNames)) for index, tagName := range tagNames { diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 9e57478c8..66f81b64a 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -17,12 +17,12 @@ type Handler struct { } // NewHandler creates a handler to serve static files. -func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler { +func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { h := &Handler{ Handler: security.MWSecureHeaders( gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), featureflags.IsEnabled("hsts"), - csp, + featureflags.IsEnabled("csp"), ), wasInstanceDisabled: wasInstanceDisabled, } @@ -36,7 +36,6 @@ func isHTML(acceptContent []string) bool { return true } } - return false } @@ -44,13 +43,11 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handler.wasInstanceDisabled() { if r.RequestURI == "/" || r.RequestURI == "/index.html" { http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) - return } } else { if strings.HasPrefix(r.RequestURI, "/timeout.html") { http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return } } diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index 43c08c870..1eaa52716 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -17,11 +17,10 @@ type fileResponse struct { } type repositoryFilePreviewPayload struct { - Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` - Reference string `json:"reference" example:"refs/heads/master"` - Username string `json:"username" example:"myGitUsername"` - Password string `json:"password" example:"myGitPassword"` - AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"` + Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` + Reference string `json:"reference" example:"refs/heads/master"` + Username string `json:"username" example:"myGitUsername"` + Password string `json:"password" example:"myGitPassword"` // Path to file whose content will be read TargetFile string `json:"targetFile" example:"docker-compose.yml"` // TLSSkipVerify skips SSL verification when cloning the Git repository @@ -69,15 +68,7 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht return httperror.InternalServerError("Unable to create temporary folder", err) } - err = handler.gitService.CloneRepository( - projectPath, - payload.Repository, - payload.Reference, - payload.Username, - payload.Password, - payload.AuthorizationType, - payload.TLSSkipVerify, - ) + err = handler.gitService.CloneRepository(projectPath, payload.Repository, payload.Reference, payload.Username, payload.Password, payload.TLSSkipVerify) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { return httperror.BadRequest("Invalid git credential", err) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 1704eb316..1ae55a002 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -81,7 +81,7 @@ type Handler struct { } // @title PortainerCE API -// @version 2.32.0 +// @version 2.31.0 // @description.markdown api-description.md // @termsOfService diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index 33b0d82cd..83ae0db51 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -46,24 +46,18 @@ var errChartNameInvalid = errors.New("invalid chart name. " + // @produce json // @param id path int true "Environment(Endpoint) identifier" // @param payload body installChartPayload true "Chart details" -// @param dryRun query bool false "Dry run" // @success 201 {object} release.Release "Created" // @failure 401 "Unauthorized" // @failure 404 "Environment(Endpoint) or ServiceAccount not found" // @failure 500 "Server error" // @router /endpoints/{id}/kubernetes/helm [post] func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true) - if err != nil { - return httperror.BadRequest("Invalid dryRun query parameter", err) - } - var payload installChartPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { return httperror.BadRequest("Invalid Helm install payload", err) } - release, err := handler.installChart(r, payload, dryRun) + release, err := handler.installChart(r, payload) if err != nil { return httperror.InternalServerError("Unable to install a chart", err) } @@ -100,7 +94,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error { return nil } -func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) { +func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { clusterAccess, httperr := handler.getHelmClusterAccess(r) if httperr != nil { return nil, httperr.Err @@ -113,7 +107,6 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry Namespace: p.Namespace, Repo: p.Repo, Atomic: p.Atomic, - DryRun: dryRun, KubernetesClusterAccess: clusterAccess, } @@ -141,14 +134,13 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry return nil, err } - if !installOpts.DryRun { - manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) - if err != nil { - return nil, err - } - if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { - return nil, err - } + manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) + if err != nil { + return nil, err + } + + if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { + return nil, err } return release, nil diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go index a7f2485e3..6612ab7f4 100644 --- a/api/http/handler/kubernetes/client.go +++ b/api/http/handler/kubernetes/client.go @@ -2,10 +2,8 @@ package kubernetes import ( "net/http" - "strconv" "github.com/portainer/portainer/api/http/middlewares" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/rs/zerolog/log" @@ -27,13 +25,7 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) } - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.") - return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err) - } - - pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID))) + pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go index 25f024303..0e226d5ec 100644 --- a/api/http/handler/kubernetes/event.go +++ b/api/http/handler/kubernetes/event.go @@ -20,7 +20,7 @@ import ( // @param id path int true "Environment identifier" // @param namespace path string true "The namespace name the events are associated to" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} []kubernetes.K8sEvent "Success" +// @success 200 {object} models.Event[] "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." @@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r // @produce json // @param id path int true "Environment identifier" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} []kubernetes.K8sEvent "Success" +// @success 200 {object} models.Event[] "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index a8a5898c8..07f6bdf3f 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -146,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *ht return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) } - cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) if !ok { return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) } @@ -179,7 +179,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { } // Check if we have a kubeclient against this auth token already, otherwise generate a new one - _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) if ok { next.ServeHTTP(w, r) return @@ -269,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } - handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli) + handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli) next.ServeHTTP(w, r) }) } diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 75dae9e69..2efde3b85 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -22,7 +22,6 @@ import ( // @produce json // @param id path int true "Environment identifier" // @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" -// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false" // @success 200 {array} portainer.K8sNamespaceInfo "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." @@ -37,12 +36,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) } - withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true) - if err != nil { - log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents") - return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err) - } - cli, httpErr := handler.prepareKubeClient(r) if httpErr != nil { log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") @@ -55,14 +48,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) } - if withUnhealthyEvents { - namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces) - if err != nil { - log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events") - return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err) - } - } - if withResourceQuota { return cli.CombineNamespacesWithResourceQuotas(namespaces, w) } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 026039833..dee14885e 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -5,10 +5,10 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/registryutils/access" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/pendingactions" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -17,7 +17,6 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" - "github.com/rs/zerolog/log" ) func hideFields(registry *portainer.Registry, hideAccesses bool) { @@ -57,20 +56,17 @@ func newHandler(bouncer security.BouncerService) *Handler { func (handler *Handler) initRouter(bouncer accessGuard) { adminRouter := handler.NewRoute().Subrouter() adminRouter.Use(bouncer.AdminAccess) + + authenticatedRouter := handler.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) - // Use registry-specific access bouncer for inspect and repositories endpoints - registryAccessRouter := handler.NewRoute().Subrouter() - registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess) - registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) - - // Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access - authenticatedRouter := handler.NewRoute().Subrouter() - authenticatedRouter.Use(bouncer.AuthenticatedAccess) + authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) } @@ -92,7 +88,9 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re } // this function validates that +// // 1. user has the appropriate authorizations to perform the request +// // 2. user has a direct or indirect access to the registry func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -100,6 +98,11 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return false, false, err + } + // Portainer admins always have access to everything if securityContext.IsAdmin { return true, true, nil @@ -125,68 +128,47 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } - // Use the enhanced registry access utility function that includes namespace validation - _, err = access.GetAccessibleRegistry( - handler.DataStore, - handler.K8sClientFactory, - securityContext.UserID, - endpointId, - registry.ID, - ) + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { - return false, false, nil // No access + return false, false, nil } - return true, false, nil -} - -// RegistryAccess defines a security check for registry-specific API endpoints. -// Authentication is required to access these endpoints. -// The user must have direct or indirect access to the specific registry being requested. -// This bouncer validates registry access using the userHasRegistryAccess logic. -func (handler *Handler) RegistryAccess(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // First ensure the user is authenticated - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized) - return - } - - // Extract registry ID from the route - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err) - return - } - - // Get the registry from the database - registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) - if handler.DataStore.IsErrObjectNotFound(err) { - httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err) - return - } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err) - return - } - - // Check if user has access to this registry - hasAccess, _, err := handler.userHasRegistryAccess(r, registry) - if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err) - return - } - if !hasAccess { - log.Debug(). - Int("registry_id", registryID). - Str("registry_name", registry.Name). - Int("user_id", int(tokenData.ID)). - Str("context", "RegistryAccessBouncer"). - Msg("User access denied to registry") - httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied) - return - } - - next.ServeHTTP(w, r) - }) + // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) + if endpointutils.IsKubernetesEndpoint(endpoint) { + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") + } + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access") + } + + authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces + + for _, namespace := range authorizedNamespaces { + // when the default namespace is authorized to use a registry, all users have the ability to use it + // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations + if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { + return true, false, nil + } + + namespacePolicy := accessPolicies[namespace] + if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { + return true, false, nil + } + } + return false, false, nil + } + + // validate access for docker environments + // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) + // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) + if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) { + return true, false, nil + } + + // when user has no access via their role, direct grant or indirect grant + // then they don't have access to the registry + return false, false, nil } diff --git a/api/http/handler/registries/registry_access_test.go b/api/http/handler/registries/registry_access_test.go deleted file mode 100644 index 8231f4d66..000000000 --- a/api/http/handler/registries/registry_access_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package registries - -import ( - "net/http" - "net/http/httptest" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/testhelpers" - - "github.com/gorilla/mux" - "github.com/stretchr/testify/assert" -) - -func Test_RegistryAccess_RequiresAuthentication(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - registry := &portainer.Registry{ - ID: 1, - Name: "test-registry", - URL: "https://registry.test.com", - } - err := store.Registry().Create(registry) - assert.NoError(t, err) - handler := &Handler{ - DataStore: store, - } - req := httptest.NewRequest(http.MethodGet, "/registries/1", nil) - req = mux.SetURLVars(req, map[string]string{"id": "1"}) - rr := httptest.NewRecorder() - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusUnauthorized, rr.Code) -} - -func Test_RegistryAccess_InvalidRegistryID(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} - err := store.User().Create(user) - assert.NoError(t, err) - - handler := &Handler{ - DataStore: store, - } - req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil) - req = mux.SetURLVars(req, map[string]string{"id": "invalid"}) - tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} - req = req.WithContext(security.StoreTokenData(req, tokenData)) - - rr := httptest.NewRecorder() - - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusBadRequest, rr.Code) -} - -func Test_RegistryAccess_RegistryNotFound(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} - err := store.User().Create(user) - assert.NoError(t, err) - - handler := &Handler{ - DataStore: store, - requestBouncer: testhelpers.NewTestRequestBouncer(), - } - req := httptest.NewRequest(http.MethodGet, "/registries/999", nil) - req = mux.SetURLVars(req, map[string]string{"id": "999"}) - tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} - req = req.WithContext(security.StoreTokenData(req, tokenData)) - - rr := httptest.NewRecorder() - - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusNotFound, rr.Code) -} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index f606a953e..a1f0bd9c5 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -4,12 +4,10 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" + httperrors "github.com/portainer/portainer/api/http/errors" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" - - "github.com/rs/zerolog/log" ) // @id RegistryInspect @@ -33,11 +31,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid registry identifier route variable", err) } - log.Debug(). - Int("registry_id", registryID). - Str("context", "RegistryInspectHandler"). - Msg("Starting registry inspection") - registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) if handler.DataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) @@ -45,12 +38,14 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) } - // Check if user is admin to determine if we should hide sensitive fields - securityContext, err := security.RetrieveRestrictedRequestContext(r) + hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) } + if !hasAccess { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } - hideFields(registry, !securityContext.IsAdmin) + hideFields(registry, !isAdmin) return response.JSON(w, registry) } diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 2bdf2b71f..8d0687694 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -19,15 +19,14 @@ import ( ) type stackGitUpdatePayload struct { - AutoUpdate *portainer.AutoUpdateSettings - Env []portainer.Pair - Prune bool - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - TLSSkipVerify bool + AutoUpdate *portainer.AutoUpdateSettings + Env []portainer.Pair + Prune bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + TLSSkipVerify bool } func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { @@ -152,19 +151,11 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * } stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: password, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: password, } - if _, err := handler.GitService.LatestCommitID( - stack.GitConfig.URL, - stack.GitConfig.ReferenceName, - stack.GitConfig.Authentication.Username, - stack.GitConfig.Authentication.Password, - stack.GitConfig.Authentication.AuthorizationType, - stack.GitConfig.TLSSkipVerify, - ); err != nil { + if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { return httperror.InternalServerError("Unable to fetch git repository", err) } } else { diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index c595808aa..e65e1e70c 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -6,7 +6,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/git" - gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" k "github.com/portainer/portainer/api/kubernetes" @@ -20,13 +19,12 @@ import ( ) type stackGitRedployPayload struct { - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - Env []portainer.Pair - Prune bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + Env []portainer.Pair + Prune bool // Force a pulling to current image with the original tag though the image is already the latest PullImage bool `example:"false"` @@ -137,16 +135,13 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if payload.RepositoryAuthentication { repositoryPassword = payload.RepositoryPassword - repositoryAuthType = payload.RepositoryAuthorizationType // When the existing stack is using the custom username/password and the password is not updated, // the stack should keep using the saved username/password if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { repositoryPassword = stack.GitConfig.Authentication.Password - repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType } repositoryUsername = payload.RepositoryUsername } @@ -157,7 +152,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) ReferenceName: stack.GitConfig.ReferenceName, Username: repositoryUsername, Password: repositoryPassword, - AuthType: repositoryAuthType, TLSSkipVerify: stack.GitConfig.TLSSkipVerify, } @@ -172,7 +166,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) return err } - newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify) + newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify) if err != nil { return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)) } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index 42ecbaa04..95195bb10 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -27,13 +27,12 @@ type kubernetesFileStackUpdatePayload struct { } type kubernetesGitStackUpdatePayload struct { - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - AutoUpdate *portainer.AutoUpdateSettings - TLSSkipVerify bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + AutoUpdate *portainer.AutoUpdateSettings + TLSSkipVerify bool } func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { @@ -77,19 +76,11 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. } stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: password, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: password, } - if _, err := handler.GitService.LatestCommitID( - stack.GitConfig.URL, - stack.GitConfig.ReferenceName, - stack.GitConfig.Authentication.Username, - stack.GitConfig.Authentication.Password, - stack.GitConfig.Authentication.AuthorizationType, - stack.GitConfig.TLSSkipVerify, - ); err != nil { + if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { return httperror.InternalServerError("Unable to fetch git repository", err) } } diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index f8f1b7786..4f8554faf 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -8,7 +8,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/edge" - "github.com/portainer/portainer/api/internal/endpointutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -59,9 +58,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { for endpointID := range tag.Endpoints { endpoint, err := tx.Endpoint().Endpoint(endpointID) - if tx.IsErrObjectNotFound(err) { - continue - } if err != nil { return httperror.InternalServerError("Unable to retrieve environment from the database", err) } @@ -107,10 +103,15 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } - edgeJobs, err := tx.EdgeJob().ReadAll() - if err != nil { - return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err) + for _, endpoint := range endpoints { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { + err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks) + if err != nil { + return httperror.InternalServerError("Unable to update environment relations in the database", err) + } + } } + for _, edgeGroup := range edgeGroups { edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool { return t == tagID @@ -122,16 +123,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { } } - for _, endpoint := range endpoints { - if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) { - continue - } - - if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil { - return httperror.InternalServerError("Unable to update environment relations in the database", err) - } - } - err = tx.Tag().Delete(tagID) if err != nil { return httperror.InternalServerError("Unable to remove the tag from the database", err) @@ -140,12 +131,19 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return nil } -func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error { +func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !tx.IsErrObjectNotFound(err) { return err } + if endpointRelation == nil { + endpointRelation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } + endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) if err != nil { return err @@ -159,25 +157,5 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End endpointRelation.EdgeStacks = stacksSet - if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil { - return err - } - - for _, edgeJob := range edgeJobs { - endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) - if err != nil { - return err - } - if slices.Contains(endpoints, endpoint.ID) { - continue - } - - delete(edgeJob.GroupLogsCollection, endpoint.ID) - - if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil { - return err - } - } - - return nil + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) } diff --git a/api/http/handler/tags/tag_delete_test.go b/api/http/handler/tags/tag_delete_test.go index c933610c5..cabf20963 100644 --- a/api/http/handler/tags/tag_delete_test.go +++ b/api/http/handler/tags/tag_delete_test.go @@ -8,20 +8,23 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" - portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { const tagsCount = 100 - handler, store := setUpHandler(t) + _, store := datastore.MustNewTestStore(t, true, false) + + user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} + if err := store.User().Create(user); err != nil { + t.Fatal("could not create admin user:", err) + } + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + // Create all the tags and add them to the same edge group var tagIDs []portainer.TagID @@ -81,128 +84,3 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { t.Fatal("the edge group is not consistent") } } - -func TestHandler_tagDelete(t *testing.T) { - t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) { - handler, store := setUpHandler(t) - - tag := &portainer.Tag{ - ID: 1, - Name: "tag-1", - Endpoints: make(map[portainer.EndpointID]bool), - EndpointGroups: make(map[portainer.EndpointGroupID]bool), - } - require.NoError(t, store.Tag().Create(tag)) - - endpointGroup := &portainer.EndpointGroup{ - ID: 2, - Name: "endpoint-group-1", - TagIDs: []portainer.TagID{tag.ID}, - } - require.NoError(t, store.EndpointGroup().Create(endpointGroup)) - - endpoint1 := &portainer.Endpoint{ - ID: 1, - Name: "endpoint-1", - GroupID: endpointGroup.ID, - } - require.NoError(t, store.Endpoint().Create(endpoint1)) - - endpoint2 := &portainer.Endpoint{ - ID: 2, - Name: "endpoint-2", - TagIDs: []portainer.TagID{tag.ID}, - } - require.NoError(t, store.Endpoint().Create(endpoint2)) - - tag.Endpoints[endpoint2.ID] = true - tag.EndpointGroups[endpointGroup.ID] = true - require.NoError(t, store.Tag().Update(tag.ID, tag)) - - dynamicEdgeGroup := &portainer.EdgeGroup{ - ID: 1, - Name: "edgegroup-1", - TagIDs: []portainer.TagID{tag.ID}, - Dynamic: true, - } - require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup)) - - staticEdgeGroup := &portainer.EdgeGroup{ - ID: 2, - Name: "edgegroup-2", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint2.ID}), - } - require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup)) - - req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil) - if err != nil { - t.Fail() - - return - } - - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - require.Equal(t, http.StatusNoContent, rec.Code) - - // Check that the tag is deleted - _, err = store.Tag().Read(tag.ID) - require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound) - - // Check that the endpoints are updated - endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID) - require.NoError(t, err) - assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags") - assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group") - - endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID) - require.NoError(t, err) - assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags") - - // Check that the dynamic edge group is updated - dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID) - require.NoError(t, err) - assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags") - assert.Equal(t, 0, dynamicEdgeGroup.EndpointIDs.Len(), "dynamic edge group should not have any endpoints") - - // Check that the static edge group is not updated - staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID) - require.NoError(t, err) - assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags") - assert.Equal(t, 1, staticEdgeGroup.EndpointIDs.Len(), "static edge group should have one endpoint") - assert.True(t, staticEdgeGroup.EndpointIDs.Contains(endpoint2.ID), "static edge group should have the endpoint-2") - }) - - // Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup - t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) { - _, store := setUpHandler(t) - // Create a tag with a reference to a non-existent endpoint - tag := &portainer.Tag{ - ID: 1, - Name: "test-tag", - Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint - EndpointGroups: make(map[portainer.EndpointGroupID]bool), - } - - err := store.Tag().Create(tag) - require.NoError(t, err) - - err = deleteTag(store, 1) - require.NoError(t, err) - }) -} - -func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) { - _, store := datastore.MustNewTestStore(t, true, false) - - user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} - if err := store.User().Create(user); err != nil { - t.Fatal("could not create admin user:", err) - } - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - return handler, store -} diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go index f9ec0135c..b834eeed9 100644 --- a/api/http/handler/templates/template_file.go +++ b/api/http/handler/templates/template_file.go @@ -5,7 +5,6 @@ import ( "slices" portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -72,15 +71,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht defer handler.cleanUp(projectPath) - if err := handler.GitService.CloneRepository( - projectPath, - template.Repository.URL, - "", - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ); err != nil { + if err := handler.GitService.CloneRepository(projectPath, template.Repository.URL, "", "", "", false); err != nil { return httperror.InternalServerError("Unable to clone git repository", err) } diff --git a/api/http/handler/templates/utils_fetch_templates.go b/api/http/handler/templates/utils_fetch_templates.go index fc5c97125..73f9bad56 100644 --- a/api/http/handler/templates/utils_fetch_templates.go +++ b/api/http/handler/templates/utils_fetch_templates.go @@ -40,13 +40,11 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError } defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + err = json.NewDecoder(resp.Body).Decode(&body) + if err != nil { return nil, httperror.InternalServerError("Unable to parse template file", err) } - for i := range body.Templates { - body.Templates[i].ID = portainer.TemplateID(i + 1) - } return body, nil } diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index d7edde333..b69e93db3 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/handler/webhooks/webhook_update.go b/api/http/handler/webhooks/webhook_update.go index 94133c49a..7a026fcd7 100644 --- a/api/http/handler/webhooks/webhook_update.go +++ b/api/http/handler/webhooks/webhook_update.go @@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/middlewares/endpoint.go b/api/http/middlewares/endpoint.go index 0050e4300..c88731dd3 100644 --- a/api/http/middlewares/endpoint.go +++ b/api/http/middlewares/endpoint.go @@ -25,12 +25,12 @@ type key int const contextEndpoint key = 0 func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc { - if endpointIDParam == "" { - endpointIDParam = "id" - } - return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + if endpointIDParam == "" { + endpointIDParam = "id" + } + endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) if err != nil { httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) @@ -51,6 +51,7 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) next.ServeHTTP(rw, request.WithContext(ctx)) + }) } } diff --git a/api/http/middlewares/plaintext_http_request.go b/api/http/middlewares/plaintext_http_request.go index e746fd819..668346098 100644 --- a/api/http/middlewares/plaintext_http_request.go +++ b/api/http/middlewares/plaintext_http_request.go @@ -3,7 +3,6 @@ package middlewares import ( "net/http" "slices" - "strings" "github.com/gorilla/csrf" ) @@ -17,45 +16,6 @@ type plainTextHTTPRequestHandler struct { next http.Handler } -// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol. -// The Forwarded header format supports: -// - Single proxy: Forwarded: by=;for=;host=;proto= -// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17 -// We take the first (leftmost) entry as it represents the original client -func parseForwardedHeaderProto(forwarded string) string { - if forwarded == "" { - return "" - } - - // Parse the first part (leftmost proxy, closest to original client) - firstPart, _, _ := strings.Cut(forwarded, ",") - firstPart = strings.TrimSpace(firstPart) - - // Split by semicolon to get key-value pairs within this proxy entry - // Format: key=value;key=value;key=value - pairs := strings.Split(firstPart, ";") - for _, pair := range pairs { - // Split by equals sign to separate key and value - key, value, found := strings.Cut(pair, "=") - if !found { - continue - } - - if strings.EqualFold(strings.TrimSpace(key), "proto") { - return strings.Trim(strings.TrimSpace(value), `"'`) - } - } - - return "" -} - -// isHTTPSRequest checks if the original request was made over HTTPS -// by examining both X-Forwarded-Proto and Forwarded headers -func isHTTPSRequest(r *http.Request) bool { - return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || - strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https") -} - func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if slices.Contains(safeMethods, r.Method) { h.next.ServeHTTP(w, r) @@ -64,7 +24,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R req := r // If original request was HTTPS (via proxy), keep CSRF checks. - if !isHTTPSRequest(r) { + if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" { req = csrf.PlaintextHTTPRequest(r) } diff --git a/api/http/middlewares/plaintext_http_request_test.go b/api/http/middlewares/plaintext_http_request_test.go deleted file mode 100644 index 33912be80..000000000 --- a/api/http/middlewares/plaintext_http_request_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package middlewares - -import ( - "testing" -) - -var tests = []struct { - name string - forwarded string - expected string -}{ - { - name: "empty header", - forwarded: "", - expected: "", - }, - { - name: "single proxy with proto=https", - forwarded: "proto=https", - expected: "https", - }, - { - name: "single proxy with proto=http", - forwarded: "proto=http", - expected: "http", - }, - { - name: "single proxy with multiple directives", - forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43", - expected: "https", - }, - { - name: "single proxy with proto in middle", - forwarded: "for=192.0.2.60;proto=https;host=example.com", - expected: "https", - }, - { - name: "single proxy with proto at end", - forwarded: "for=192.0.2.60;host=example.com;proto=https", - expected: "https", - }, - { - name: "multiple proxies - takes first", - forwarded: "proto=https, proto=http", - expected: "https", - }, - { - name: "multiple proxies with complex format", - forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http", - expected: "https", - }, - { - name: "multiple proxies with for directive only", - forwarded: "for=192.0.2.43, for=198.51.100.17", - expected: "", - }, - { - name: "multiple proxies with proto only in second", - forwarded: "for=192.0.2.43, proto=https", - expected: "", - }, - { - name: "multiple proxies with proto only in first", - forwarded: "proto=https, for=198.51.100.17", - expected: "https", - }, - { - name: "quoted protocol value", - forwarded: "proto=\"https\"", - expected: "https", - }, - { - name: "single quoted protocol value", - forwarded: "proto='https'", - expected: "https", - }, - { - name: "mixed case protocol", - forwarded: "proto=HTTPS", - expected: "HTTPS", - }, - { - name: "no proto directive", - forwarded: "for=192.0.2.60;by=203.0.113.43", - expected: "", - }, - { - name: "empty proto value", - forwarded: "proto=", - expected: "", - }, - { - name: "whitespace around values", - forwarded: " proto = https ", - expected: "https", - }, - { - name: "whitespace around semicolons", - forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43", - expected: "https", - }, - { - name: "whitespace around commas", - forwarded: "proto=https , proto=http", - expected: "https", - }, - { - name: "IPv6 address in for directive", - forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https", - expected: "https", - }, - { - name: "complex multiple proxies with IPv6", - forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http", - expected: "https", - }, - { - name: "obfuscated identifiers", - forwarded: "for=_mdn;proto=https", - expected: "https", - }, - { - name: "unknown identifier", - forwarded: "for=unknown;proto=https", - expected: "https", - }, - { - name: "malformed key-value pair", - forwarded: "proto", - expected: "", - }, - { - name: "malformed key-value pair with equals", - forwarded: "proto=", - expected: "", - }, - { - name: "multiple equals signs", - forwarded: "proto=https=extra", - expected: "https=extra", - }, - { - name: "mixed case directive name", - forwarded: "PROTO=https", - expected: "https", - }, - { - name: "mixed case directive name with spaces", - forwarded: " Proto = https ", - expected: "https", - }, -} - -func TestParseForwardedHeaderProto(t *testing.T) { - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseForwardedHeaderProto(tt.forwarded) - if result != tt.expected { - t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected) - } - }) - } -} - -func FuzzParseForwardedHeaderProto(f *testing.F) { - for _, t := range tests { - f.Add(t.forwarded) - } - - f.Fuzz(func(t *testing.T, forwarded string) { - parseForwardedHeaderProto(forwarded) - }) -} diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index 4759d9214..fcb49b23d 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -38,30 +38,14 @@ type K8sApplication struct { Labels map[string]string `json:"Labels,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` - CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"` } type Metadata struct { Labels map[string]string `json:"labels"` } -type CustomResourceMetadata struct { - Kind string `json:"kind"` - APIVersion string `json:"apiVersion"` - Plural string `json:"plural"` -} - type Pod struct { - Name string `json:"Name"` - ContainerName string `json:"ContainerName"` - Image string `json:"Image"` - ImagePullPolicy string `json:"ImagePullPolicy"` - Status string `json:"Status"` - NodeName string `json:"NodeName"` - PodIP string `json:"PodIP"` - UID string `json:"Uid"` - Resource K8sApplicationResource `json:"Resource,omitempty"` - CreationDate time.Time `json:"CreationDate"` + Status string `json:"Status"` } type Configuration struct { @@ -88,8 +72,8 @@ type TLSInfo struct { // Existing types type K8sApplicationResource struct { - CPURequest float64 `json:"CpuRequest,omitempty"` - CPULimit float64 `json:"CpuLimit,omitempty"` - MemoryRequest int64 `json:"MemoryRequest,omitempty"` - MemoryLimit int64 `json:"MemoryLimit,omitempty"` + CPURequest float64 `json:"CpuRequest"` + CPULimit float64 `json:"CpuLimit"` + MemoryRequest int64 `json:"MemoryRequest"` + MemoryLimit int64 `json:"MemoryLimit"` } diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index ac25a7b7a..e945d38da 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -35,7 +35,7 @@ type ( func getUniqueElements(items string) []string { xs := strings.Split(items, ",") xs = slicesx.Map(xs, strings.TrimSpace) - xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) + xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 }) return slicesx.Unique(xs) } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index 7036853c7..ecf7935f1 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -55,13 +55,12 @@ func createRegistryAuthenticationHeader( return } - if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil { + if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil { return } authenticationHeader.Serveraddress = matchingRegistry.URL - authenticationHeader.Username = matchingRegistry.Username - authenticationHeader.Password = matchingRegistry.Password + authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) return } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index dae72ecc1..49f1cd501 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -15,7 +15,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -419,14 +418,7 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error } repositoryURL := remote[:len(remote)-4] - latestCommitID, err := transport.gitService.LatestCommitID( - repositoryURL, - "", - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ) + latestCommitID, err := transport.gitService.LatestCommitID(repositoryURL, "", "", "", false) if err != nil { return err } diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index b45629630..28d05dec5 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -24,12 +24,11 @@ type ( kubernetesTokenCacheManager *kubernetes.TokenCacheManager gitService portainer.GitService snapshotService portainer.SnapshotService - jwtService portainer.JWTService } ) // NewProxyFactory returns a pointer to a new instance of a ProxyFactory -func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) *ProxyFactory { +func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory { return &ProxyFactory{ dataStore: dataStore, signatureService: signatureService, @@ -39,7 +38,6 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine kubernetesTokenCacheManager: kubernetesTokenCacheManager, gitService: gitService, snapshotService: snapshotService, - jwtService: jwtService, } } diff --git a/api/http/proxy/factory/github/client.go b/api/http/proxy/factory/github/client.go deleted file mode 100644 index 74dcfb994..000000000 --- a/api/http/proxy/factory/github/client.go +++ /dev/null @@ -1,108 +0,0 @@ -package github - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/segmentio/encoding/json" - "oras.land/oras-go/v2/registry/remote/retry" -) - -const GitHubAPIHost = "https://api.github.com" - -// Package represents a GitHub container package -type Package struct { - Name string `json:"name"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` -} - -// Client represents a GitHub API client -type Client struct { - httpClient *http.Client - baseURL string -} - -// NewClient creates a new GitHub API client -func NewClient(token string) *Client { - return &Client{ - httpClient: NewHTTPClient(token), - baseURL: GitHubAPIHost, - } -} - -// GetContainerPackages fetches container packages for the configured namespace -// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras -func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) { - // Determine the namespace (user or organisation) for the request - namespace := "user" - if useOrganisation { - namespace = "orgs/" + organisationName - } - - // Build the full URL for listing container packages - url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var packages []Package - if err := json.Unmarshal(body, &packages); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Extract repository names in the form "owner/name" - repositories := make([]string, len(packages)) - for i, pkg := range packages { - repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name)) - } - - return repositories, nil -} - -// NewHTTPClient creates a new HTTP client configured for GitHub API requests -func NewHTTPClient(token string) *http.Client { - return &http.Client{ - Transport: &tokenTransport{ - token: token, - transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling - }, - Timeout: 1 * time.Minute, - } -} - -// tokenTransport automatically adds the Bearer token header to requests -type tokenTransport struct { - token string - transport http.RoundTripper -} - -func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if t.token != "" { - req.Header.Set("Authorization", "Bearer "+t.token) - req.Header.Set("Accept", "application/vnd.github+json") - } - return t.transport.RoundTrip(req) -} diff --git a/api/http/proxy/factory/gitlab/client.go b/api/http/proxy/factory/gitlab/client.go deleted file mode 100644 index 13d07e18b..000000000 --- a/api/http/proxy/factory/gitlab/client.go +++ /dev/null @@ -1,130 +0,0 @@ -package gitlab - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "time" - - "github.com/segmentio/encoding/json" - "oras.land/oras-go/v2/registry/remote/retry" -) - -// Repository represents a GitLab registry repository -type Repository struct { - ID int `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - ProjectID int `json:"project_id"` - Location string `json:"location"` - CreatedAt string `json:"created_at"` - Status string `json:"status"` -} - -// Client represents a GitLab API client -type Client struct { - httpClient *http.Client - baseURL string -} - -// NewClient creates a new GitLab API client -// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands). -// if we need to support other commands, consider using the gitlab client library. -func NewClient(baseURL, token string) *Client { - return &Client{ - httpClient: NewHTTPClient(token), - baseURL: baseURL, - } -} - -// GetRegistryRepositoryNames fetches registry repository names for a given project. -// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras -func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) { - url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var repositories []Repository - if err := json.Unmarshal(body, &repositories); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Extract repository names - names := make([]string, len(repositories)) - for i, repo := range repositories { - // the full path is required for further repo operations - names[i] = repo.Path - } - - return names, nil -} - -type Transport struct { - httpTransport *http.Transport -} - -// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport -// interface for proxying requests to the Gitlab API. -func NewTransport() *Transport { - return &Transport{ - httpTransport: &http.Transport{}, - } -} - -// RoundTrip is the implementation of the http.RoundTripper interface -func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { - token := request.Header.Get("Private-Token") - if token == "" { - return nil, errors.New("no gitlab token provided") - } - - r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) - if err != nil { - return nil, err - } - - r.Header.Set("Private-Token", token) - return transport.httpTransport.RoundTrip(r) -} - -// NewHTTPClient creates a new HTTP client configured for GitLab API requests -func NewHTTPClient(token string) *http.Client { - return &http.Client{ - Transport: &tokenTransport{ - token: token, - transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling - }, - Timeout: 1 * time.Minute, - } -} - -// tokenTransport automatically adds the Private-Token header to requests -type tokenTransport struct { - token string - transport http.RoundTripper -} - -func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("Private-Token", t.token) - return t.transport.RoundTrip(req) -} diff --git a/api/http/proxy/factory/gitlab/transport.go b/api/http/proxy/factory/gitlab/transport.go new file mode 100644 index 000000000..7e1804c45 --- /dev/null +++ b/api/http/proxy/factory/gitlab/transport.go @@ -0,0 +1,34 @@ +package gitlab + +import ( + "errors" + "net/http" +) + +type Transport struct { + httpTransport *http.Transport +} + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Gitlab API. +func NewTransport() *Transport { + return &Transport{ + httpTransport: &http.Transport{}, + } +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("no gitlab token provided") + } + + r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) + if err != nil { + return nil, err + } + + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index ea08467e5..eceee181a 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -38,7 +38,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService) + transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) if err != nil { return nil, err } @@ -74,7 +74,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp endpointURL.Scheme = "http" proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL) - proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.jwtService) + proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory) return proxy, nil } @@ -105,7 +105,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En } proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go index b127b85fd..b6ab548ae 100644 --- a/api/http/proxy/factory/kubernetes/agent_transport.go +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -16,7 +16,7 @@ type agentTransport struct { } // NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent -func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *agentTransport { +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *agentTransport { transport := &agentTransport{ baseTransport: newBaseTransport( &http.Transport{ @@ -26,7 +26,6 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo endpoint, k8sClientFactory, dataStore, - jwtService, ), signatureService: signatureService, } diff --git a/api/http/proxy/factory/kubernetes/edge_transport.go b/api/http/proxy/factory/kubernetes/edge_transport.go index 73946114e..4eed6934a 100644 --- a/api/http/proxy/factory/kubernetes/edge_transport.go +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -16,7 +16,7 @@ type edgeTransport struct { } // NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent -func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, jwtService portainer.JWTService) *edgeTransport { +func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory) *edgeTransport { transport := &edgeTransport{ reverseTunnelService: reverseTunnelService, signatureService: signatureService, @@ -26,7 +26,6 @@ func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portain endpoint, k8sClientFactory, dataStore, - jwtService, ), } diff --git a/api/http/proxy/factory/kubernetes/local_transport.go b/api/http/proxy/factory/kubernetes/local_transport.go index 6fe255ff6..4ae4082d9 100644 --- a/api/http/proxy/factory/kubernetes/local_transport.go +++ b/api/http/proxy/factory/kubernetes/local_transport.go @@ -14,7 +14,7 @@ type localTransport struct { } // NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API -func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*localTransport, error) { +func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) (*localTransport, error) { config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true) if err != nil { return nil, err @@ -29,7 +29,6 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, endpoint, k8sClientFactory, dataStore, - jwtService, ), } diff --git a/api/http/proxy/factory/kubernetes/pods.go b/api/http/proxy/factory/kubernetes/pods.go index a2e5f1860..6c36e079a 100644 --- a/api/http/proxy/factory/kubernetes/pods.go +++ b/api/http/proxy/factory/kubernetes/pods.go @@ -2,18 +2,12 @@ package kubernetes import ( "net/http" - "strings" ) -func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace string) (*http.Response, error) { +func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { if request.Method == http.MethodDelete { transport.refreshRegistry(request, namespace) } - if request.Method == http.MethodPost && strings.Contains(request.URL.Path, "/exec") { - if err := transport.addTokenForExec(request); err != nil { - return nil, err - } - } return transport.executeKubernetesRequest(request) } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index b4d06bcce..76e9daa68 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -26,17 +26,15 @@ type baseTransport struct { endpoint *portainer.Endpoint k8sClientFactory *cli.ClientFactory dataStore dataservices.DataStore - jwtService portainer.JWTService } -func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *baseTransport { +func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *baseTransport { return &baseTransport{ httpTransport: httpTransport, tokenManager: tokenManager, endpoint: endpoint, k8sClientFactory: k8sClientFactory, dataStore: dataStore, - jwtService: jwtService, } } @@ -60,7 +58,6 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (* switch { case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): - transport.k8sClientFactory.ClearClientCache() defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) return transport.executeKubernetesRequest(request) case strings.EqualFold(requestPath, "/namespaces"): @@ -84,7 +81,7 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu switch { case strings.HasPrefix(requestPath, "pods"): - return transport.proxyPodsRequest(request, namespace) + return transport.proxyPodsRequest(request, namespace, requestPath) case strings.HasPrefix(requestPath, "deployments"): return transport.proxyDeploymentsRequest(request, namespace, requestPath) case requestPath == "" && request.Method == "DELETE": @@ -94,23 +91,6 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu } } -// addTokenForExec injects a kubeconfig token into the request header -// this is only used with kubeconfig for kubectl exec requests -func (transport *baseTransport) addTokenForExec(request *http.Request) error { - tokenData, err := security.RetrieveTokenData(request) - if err != nil { - return err - } - - token, err := transport.jwtService.GenerateTokenForKubeconfig(tokenData) - if err != nil { - return err - } - - request.Header.Set("Authorization", "Bearer "+token) - return nil -} - func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) { resp, err := transport.httpTransport.RoundTrip(request) diff --git a/api/http/proxy/factory/kubernetes/transport_test.go b/api/http/proxy/factory/kubernetes/transport_test.go deleted file mode 100644 index 713714d93..000000000 --- a/api/http/proxy/factory/kubernetes/transport_test.go +++ /dev/null @@ -1,359 +0,0 @@ -package kubernetes - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/jwt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// MockJWTService implements portainer.JWTService for testing -type MockJWTService struct { - generateTokenFunc func(data *portainer.TokenData) (string, error) -} - -func (m *MockJWTService) GenerateToken(data *portainer.TokenData) (string, time.Time, error) { - if m.generateTokenFunc != nil { - token, err := m.generateTokenFunc(data) - return token, time.Now().Add(24 * time.Hour), err - } - return "mock-token", time.Now().Add(24 * time.Hour), nil -} - -func (m *MockJWTService) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { - if m.generateTokenFunc != nil { - return m.generateTokenFunc(data) - } - return "mock-kubeconfig-token", nil -} - -func (m *MockJWTService) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) { - return &portainer.TokenData{ID: 1, Username: "mock", Role: portainer.AdministratorRole}, "mock-id", time.Now().Add(24 * time.Hour), nil -} - -func (m *MockJWTService) SetUserSessionDuration(userSessionDuration time.Duration) { - // Mock implementation - not used in tests -} - -func TestBaseTransport_AddTokenForExec(t *testing.T) { - // Setup test store and JWT service - _, store := datastore.MustNewTestStore(t, true, false) - - // Create test users - adminUser := &portainer.User{ - ID: 1, - Username: "admin", - Role: portainer.AdministratorRole, - } - err := store.User().Create(adminUser) - require.NoError(t, err) - - standardUser := &portainer.User{ - ID: 2, - Username: "standard", - Role: portainer.StandardUserRole, - } - err = store.User().Create(standardUser) - require.NoError(t, err) - - // Create JWT service - jwtService, err := jwt.NewService("24h", store) - require.NoError(t, err) - - // Create base transport - transport := &baseTransport{ - jwtService: jwtService, - } - - tests := []struct { - name string - tokenData *portainer.TokenData - setupRequest func(*http.Request) *http.Request - expectError bool - errorMsg string - expectPanic bool - verifyResponse func(*testing.T, *http.Request, *portainer.TokenData) - }{ - { - name: "admin user - successful token generation", - tokenData: &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - }, - setupRequest: func(req *http.Request) *http.Request { - return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - })) - }, - expectError: false, - verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { - authHeader := req.Header.Get("Authorization") - assert.NotEmpty(t, authHeader) - assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) - - token := authHeader[7:] // Remove "Bearer " prefix - parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) - assert.NoError(t, err) - assert.Equal(t, tokenData.ID, parsedTokenData.ID) - assert.Equal(t, tokenData.Username, parsedTokenData.Username) - assert.Equal(t, tokenData.Role, parsedTokenData.Role) - }, - }, - { - name: "standard user - successful token generation", - tokenData: &portainer.TokenData{ - ID: standardUser.ID, - Username: standardUser.Username, - Role: standardUser.Role, - }, - setupRequest: func(req *http.Request) *http.Request { - return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ - ID: standardUser.ID, - Username: standardUser.Username, - Role: standardUser.Role, - })) - }, - expectError: false, - verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { - authHeader := req.Header.Get("Authorization") - assert.NotEmpty(t, authHeader) - assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) - - token := authHeader[7:] // Remove "Bearer " prefix - parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) - assert.NoError(t, err) - assert.Equal(t, tokenData.ID, parsedTokenData.ID) - assert.Equal(t, tokenData.Username, parsedTokenData.Username) - assert.Equal(t, tokenData.Role, parsedTokenData.Role) - }, - }, - { - name: "request without token data in context", - tokenData: nil, - setupRequest: func(req *http.Request) *http.Request { - return req // Don't add token data to context - }, - expectError: true, - errorMsg: "Unable to find JWT data in request context", - }, - { - name: "request with nil token data", - tokenData: nil, - setupRequest: func(req *http.Request) *http.Request { - return req.WithContext(security.StoreTokenData(req, nil)) - }, - expectPanic: true, - }, - { - name: "JWT service failure", - tokenData: &portainer.TokenData{ - ID: 1, - Username: "test", - Role: portainer.AdministratorRole, - }, - setupRequest: func(req *http.Request) *http.Request { - return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ - ID: 1, - Username: "test", - Role: portainer.AdministratorRole, - })) - }, - expectPanic: true, - }, - { - name: "verify authorization header format", - tokenData: &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - }, - setupRequest: func(req *http.Request) *http.Request { - return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - })) - }, - expectError: false, - verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { - authHeader := req.Header.Get("Authorization") - assert.NotEmpty(t, authHeader) - assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) - - token := authHeader[7:] // Remove "Bearer " prefix - assert.NotEmpty(t, token) - assert.Greater(t, len(token), 0, "Token should not be empty") - }, - }, - { - name: "verify header is overwritten on subsequent calls", - tokenData: &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - }, - setupRequest: func(req *http.Request) *http.Request { - req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ - ID: adminUser.ID, - Username: adminUser.Username, - Role: adminUser.Role, - })) - // Set an existing Authorization header - req.Header.Set("Authorization", "Bearer old-token") - return req - }, - expectError: false, - verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) { - authHeader := req.Header.Get("Authorization") - assert.NotEqual(t, "Bearer old-token", authHeader) - assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) - - token := authHeader[7:] // Remove "Bearer " prefix - parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token) - assert.NoError(t, err) - assert.Equal(t, tokenData.ID, parsedTokenData.ID) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create request - request := httptest.NewRequest("GET", "/", nil) - request = tt.setupRequest(request) - - // Determine which transport to use based on test case - var testTransport *baseTransport - if tt.name == "JWT service failure" { - testTransport = &baseTransport{ - jwtService: nil, - } - } else { - testTransport = transport - } - - // Call the function - if tt.expectPanic { - assert.Panics(t, func() { - _ = testTransport.addTokenForExec(request) - }) - return - } - - err := testTransport.addTokenForExec(request) - - // Check results - if tt.expectError { - assert.Error(t, err) - if tt.errorMsg != "" { - assert.Contains(t, err.Error(), tt.errorMsg) - } - } else { - assert.NoError(t, err) - if tt.verifyResponse != nil { - tt.verifyResponse(t, request, tt.tokenData) - } - } - }) - } -} - -func TestBaseTransport_AddTokenForExec_Integration(t *testing.T) { - // Create a test HTTP server to capture requests - var capturedRequest *http.Request - var capturedHeaders http.Header - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedRequest = r - capturedHeaders = r.Header.Clone() - w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) - })) - defer testServer.Close() - - // Create mock JWT service - mockJWTService := &MockJWTService{ - generateTokenFunc: func(data *portainer.TokenData) (string, error) { - return "mock-token-" + data.Username, nil - }, - } - - // Create base transport - transport := &baseTransport{ - httpTransport: &http.Transport{}, - jwtService: mockJWTService, - } - - tests := []struct { - name string - tokenData *portainer.TokenData - requestPath string - expectedToken string - }{ - { - name: "admin user exec request", - tokenData: &portainer.TokenData{ - ID: 1, - Username: "admin", - Role: portainer.AdministratorRole, - }, - requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec", - expectedToken: "mock-token-admin", - }, - { - name: "standard user exec request", - tokenData: &portainer.TokenData{ - ID: 2, - Username: "standard", - Role: portainer.StandardUserRole, - }, - requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec", - expectedToken: "mock-token-standard", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Reset captured data - capturedRequest = nil - capturedHeaders = nil - - // Create request to the test server - request, err := http.NewRequest("POST", testServer.URL+tt.requestPath, strings.NewReader("")) - require.NoError(t, err) - - // Add token data to request context - request = request.WithContext(security.StoreTokenData(request, tt.tokenData)) - - // Call proxyPodsRequest which triggers addTokenForExec for POST /exec requests - resp, err := transport.proxyPodsRequest(request, "default") - require.NoError(t, err) - defer resp.Body.Close() - - // Verify the response - assert.Equal(t, http.StatusOK, resp.StatusCode) - - // Verify the request was captured - assert.NotNil(t, capturedRequest) - assert.Equal(t, "POST", capturedRequest.Method) - assert.Equal(t, tt.requestPath, capturedRequest.URL.Path) - - // Verify the authorization header was set correctly - capturedAuthHeader := capturedHeaders.Get("Authorization") - assert.NotEmpty(t, capturedAuthHeader) - assert.True(t, strings.HasPrefix(capturedAuthHeader, "Bearer ")) - assert.Equal(t, "Bearer "+tt.expectedToken, capturedAuthHeader) - }) - } -} diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go index a1bb3fa28..c40e6c485 100644 --- a/api/http/proxy/factory/reverse_proxy.go +++ b/api/http/proxy/factory/reverse_proxy.go @@ -9,20 +9,17 @@ import ( // Note that we discard any non-canonical headers by design var allowedHeaders = map[string]struct{}{ - "Accept": {}, - "Accept-Encoding": {}, - "Accept-Language": {}, - "Cache-Control": {}, - "Connection": {}, - "Content-Length": {}, - "Content-Type": {}, - "Private-Token": {}, - "Upgrade": {}, - "User-Agent": {}, - "X-Portaineragent-Target": {}, - "X-Portainer-Volumename": {}, - "X-Registry-Auth": {}, - "X-Stream-Protocol-Version": {}, + "Accept": {}, + "Accept-Encoding": {}, + "Accept-Language": {}, + "Cache-Control": {}, + "Content-Length": {}, + "Content-Type": {}, + "Private-Token": {}, + "User-Agent": {}, + "X-Portaineragent-Target": {}, + "X-Portainer-Volumename": {}, + "X-Registry-Auth": {}, } // newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 477bc547b..16f822028 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -32,8 +32,8 @@ func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager { } } -func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) { - manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService) +func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) { + manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService) } // CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies. diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 55b7faecc..eb240692d 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -35,7 +35,6 @@ type ( JWTAuthLookup(*http.Request) (*portainer.TokenData, error) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error RevokeJWT(string) - DisableCSP() } // RequestBouncer represents an entity that manages API request accesses @@ -73,7 +72,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW jwtService: jwtService, apiKeyService: apiKeyService, hsts: featureflags.IsEnabled("hsts"), - csp: true, + csp: featureflags.IsEnabled("csp"), } go b.cleanUpExpiredJWT() @@ -81,11 +80,6 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW return b } -// DisableCSP disables Content Security Policy -func (bouncer *RequestBouncer) DisableCSP() { - bouncer.csp = false -} - // PublicAccess defines a security check for public API endpoints. // No authentication is required to access these endpoints. func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { @@ -534,7 +528,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler { } if csp { - w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net; frame-ancestors 'none';") + w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud") } w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go index 3dd42fdc5..4d84dcfee 100644 --- a/api/http/security/bouncer_test.go +++ b/api/http/security/bouncer_test.go @@ -530,34 +530,3 @@ func TestJWTRevocation(t *testing.T) { require.Equal(t, 1, revokeLen()) } - -func TestCSPHeaderDefault(t *testing.T) { - b := NewRequestBouncer(nil, nil, nil) - - srv := httptest.NewServer( - b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), - ) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/") - require.NoError(t, err) - defer resp.Body.Close() - - require.Contains(t, resp.Header, "Content-Security-Policy") -} - -func TestCSPHeaderDisabled(t *testing.T) { - b := NewRequestBouncer(nil, nil, nil) - b.DisableCSP() - - srv := httptest.NewServer( - b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), - ) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/") - require.NoError(t, err) - defer resp.Body.Close() - - require.NotContains(t, resp.Header, "Content-Security-Policy") -} diff --git a/api/http/server.go b/api/http/server.go index 8f073ce58..88d131650 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -77,7 +77,6 @@ type Server struct { AuthorizationService *authorization.Service BindAddress string BindAddressHTTPS string - CSP bool HTTPEnabled bool AssetsPath string Status *portainer.Status @@ -114,7 +113,6 @@ type Server struct { PendingActionsService *pendingactions.PendingActionsService PlatformService platform.Service PullLimitCheckDisabled bool - TrustedOrigins []string } // Start starts the HTTP server @@ -122,16 +120,13 @@ func (server *Server) Start() error { kubernetesTokenCacheManager := server.KubernetesTokenCacheManager requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) - if !server.CSP { - requestBouncer.DisableCSP() - } rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) offlineGate := offlinegate.NewOfflineGate() passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings()) - var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker) authHandler.DataStore = server.DataStore authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -204,7 +199,7 @@ func (server *Server) Start() error { var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) - var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) @@ -341,7 +336,7 @@ func (server *Server) Start() error { handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler)) - handler, err := csrf.WithProtect(handler, server.TrustedOrigins) + handler, err := csrf.WithProtect(handler) if err != nil { return errors.Wrap(err, "failed to create CSRF middleware") } diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go index eae4fedce..64aa296a5 100644 --- a/api/internal/edge/edgegroup.go +++ b/api/internal/edge/edgegroup.go @@ -1,6 +1,8 @@ package edge import ( + "slices" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" @@ -10,7 +12,7 @@ import ( // EdgeGroupRelatedEndpoints returns a list of environments(endpoints) related to this Edge group func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup) []portainer.EndpointID { if !edgeGroup.Dynamic { - return edgeGroup.EndpointIDs.ToSlice() + return edgeGroup.Endpoints } endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{} @@ -70,7 +72,7 @@ func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore // edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint) func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool { if !edgeGroup.Dynamic { - return edgeGroup.EndpointIDs.Contains(endpoint.ID) + return slices.Contains(edgeGroup.Endpoints, endpoint.ID) } endpointTags := tag.Set(endpoint.TagIDs) diff --git a/api/internal/edge/edgegroup_benchmark_test.go b/api/internal/edge/edgegroup_benchmark_test.go deleted file mode 100644 index 861db09fc..000000000 --- a/api/internal/edge/edgegroup_benchmark_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package edge - -import ( - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/roar" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" -) - -const n = 1_000_000 - -func BenchmarkWriteEdgeGroupOld(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var endpointIDs []portainer.EndpointID - - for i := range n { - endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) - } - - for b.Loop() { - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - Endpoints: endpointIDs, - }) - require.NoError(b, err) - } -} - -func BenchmarkWriteEdgeGroupNew(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var ts []portainer.EndpointID - - for i := range n { - ts = append(ts, portainer.EndpointID(i+1)) - } - - endpointIDs := roar.FromSlice(ts) - - for b.Loop() { - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - EndpointIDs: endpointIDs, - }) - require.NoError(b, err) - } -} - -func BenchmarkReadEdgeGroupOld(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var endpointIDs []portainer.EndpointID - - for i := range n { - endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - Endpoints: endpointIDs, - }) - require.NoError(b, err) - - for b.Loop() { - _, err := store.EdgeGroup().ReadAll() - require.NoError(b, err) - } -} - -func BenchmarkReadEdgeGroupNew(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var ts []portainer.EndpointID - - for i := range n { - ts = append(ts, portainer.EndpointID(i+1)) - } - - endpointIDs := roar.FromSlice(ts) - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - EndpointIDs: endpointIDs, - }) - require.NoError(b, err) - - for b.Loop() { - _, err := store.EdgeGroup().ReadAll() - require.NoError(b, err) - } -} diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go index c0ecb5caf..5932a5ec8 100644 --- a/api/internal/edge/edgestacks/service.go +++ b/api/internal/edge/edgestacks/service.go @@ -129,6 +129,9 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg for _, endpointID := range relatedEndpointIds { relation, err := endpointRelationService.EndpointRelation(endpointID) if err != nil { + if tx.IsErrObjectNotFound(err) { + continue + } return fmt.Errorf("unable to find endpoint relation in database: %w", err) } diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index f596ae0d5..6b7eb1c2d 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -249,19 +249,3 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine return defaultInterval } - -func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error { - if !IsEdgeEndpoint(endpoint) { - return nil - } - - relation := &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - - if err := tx.EndpointRelation().Create(relation); err != nil { - return err - } - return nil -} diff --git a/api/internal/registryutils/access/access.go b/api/internal/registryutils/access/access.go index bfa5181c0..0d14cba39 100644 --- a/api/internal/registryutils/access/access.go +++ b/api/internal/registryutils/access/access.go @@ -2,82 +2,40 @@ package access import ( "errors" - "fmt" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/kubernetes" - "github.com/portainer/portainer/api/kubernetes/cli" ) func hasPermission( dataStore dataservices.DataStore, - k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registry *portainer.Registry, ) (hasPermission bool, err error) { user, err := dataStore.User().Read(userID) if err != nil { - return false, err + return } if user.Role == portainer.AdministratorRole { - return true, nil - } - - endpoint, err := dataStore.Endpoint().Endpoint(endpointID) - if err != nil { - return false, err + return true, err } teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) if err != nil { - return false, err + return } - // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) - if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil { - kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint) - if err != nil { - return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err) - } - accessPolicies, err := kcl.GetNamespaceAccessPolicies() - if err != nil { - return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err) - } - - authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces - - for _, namespace := range authorizedNamespaces { - // when the default namespace is authorized to use a registry, all users have the ability to use it - // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations - if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { - return true, nil - } - - namespacePolicy := accessPolicies[namespace] - if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { - return true, nil - } - } - return false, nil - } - - // validate access for docker environments - // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) - // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) - return hasPermission, nil + return } // GetAccessibleRegistry get the registry if the user has permission func GetAccessibleRegistry( dataStore dataservices.DataStore, - k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registryID portainer.RegistryID, @@ -88,7 +46,7 @@ func GetAccessibleRegistry( return } - hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry) + hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) if err != nil { return } diff --git a/api/internal/registryutils/ecr_reg_token.go b/api/internal/registryutils/ecr_reg_token.go index 6e9a754bf..cbcceb982 100644 --- a/api/internal/registryutils/ecr_reg_token.go +++ b/api/internal/registryutils/ecr_reg_token.go @@ -62,26 +62,3 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password return } - -// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token -// and setting effective credentials on the registry when authentication is enabled. -// This function modifies the registry in-place by setting Username and Password to the effective values. -func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error { - if !registry.Authentication { - return nil - } - - if err := EnsureRegTokenValid(tx, registry); err != nil { - return err - } - - username, password, err := GetRegEffectiveCredential(registry) - if err != nil { - return err - } - - registry.Username = username - registry.Password = password - - return nil -} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 19254f540..392f21e97 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/api/database" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices/errors" - "github.com/portainer/portainer/api/slicesx" ) var _ dataservices.DataStore = &testDatastore{} @@ -153,17 +152,8 @@ type stubUserService struct { users []portainer.User } -func (s *stubUserService) BucketName() string { return "users" } -func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) { - filtered := s.users - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil -} - +func (s *stubUserService) BucketName() string { return "users" } +func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil } func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { return s.users, nil } @@ -181,16 +171,8 @@ type stubEdgeJobService struct { jobs []portainer.EdgeJob } -func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } -func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) { - filtered := s.jobs - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil -} +func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } +func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil } // WithEdgeJobs option will instruct testDatastore to return provided jobs func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { @@ -380,14 +362,8 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) return nil, errors.ErrObjectNotFound } -func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) { - filtered := s.stacks - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil +func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) { + return s.stacks, nil } func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) { diff --git a/api/internal/testhelpers/git_service.go b/api/internal/testhelpers/git_service.go index 6b1a352ee..6af1b6459 100644 --- a/api/internal/testhelpers/git_service.go +++ b/api/internal/testhelpers/git_service.go @@ -1,9 +1,6 @@ package testhelpers -import ( - portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" -) +import portainer "github.com/portainer/portainer/api" type gitService struct { cloneErr error @@ -18,50 +15,18 @@ func NewGitService(cloneErr error, id string) portainer.GitService { } } -func (g *gitService) CloneRepository( - destination, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *gitService) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error { return g.cloneErr } -func (g *gitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return g.id, nil } -func (g *gitService) ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { +func (g *gitService) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { return nil, nil } -func (g *gitService) ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includedExts []string, - tlsSkipVerify bool, -) ([]string, error) { +func (g *gitService) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) { return nil, nil } diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index 0586dffef..b89154549 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -60,8 +60,6 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, func (testRequestBouncer) RevokeJWT(jti string) {} -func (testRequestBouncer) DisableCSP() {} - // AddTestSecurityCookie adds a security cookie to the request func AddTestSecurityCookie(r *http.Request, jwt string) { r.AddCookie(&http.Cookie{ diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 550ade1d3..a40a865f1 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -77,26 +77,9 @@ func (factory *ClientFactory) ClearClientCache() { factory.endpointProxyClients.Flush() } -// ClearClientCache removes all cached kube clients for a userId -func (factory *ClientFactory) ClearUserClientCache(userID string) { - for key := range factory.endpointProxyClients.Items() { - if strings.HasSuffix(key, "."+userID) { - factory.endpointProxyClients.Delete(key) - } - } -} - // Remove the cached kube client so a new one can be created func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) - - endpointPrefix := strconv.Itoa(int(endpointID)) + "." - - for key := range factory.endpointProxyClients.Items() { - if strings.HasPrefix(key, endpointPrefix) { - factory.endpointProxyClients.Delete(key) - } - } } func (factory *ClientFactory) GetAddrHTTPS() string { @@ -121,24 +104,6 @@ func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoi return kcl, nil } -// GetPrivilegedUserKubeClient checks if an existing admin client is already registered for the environment(endpoint) and user and returns it if one is found. -// If no client is registered, it will create a new client, register it, and returns it. -func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID string) (*KubeClient, error) { - key := strconv.Itoa(int(endpoint.ID)) + ".admin." + userID - pcl, ok := factory.endpointProxyClients.Get(key) - if ok { - return pcl.(*KubeClient), nil - } - - kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) - if err != nil { - return nil, err - } - - factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) - return kcl, nil -} - // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. @@ -191,9 +156,8 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain } return &KubeClient{ - cli: cli, - instanceID: factory.instanceID, - IsKubeAdmin: true, + cli: cli, + instanceID: factory.instanceID, }, nil } diff --git a/api/kubernetes/cli/client_test.go b/api/kubernetes/cli/client_test.go deleted file mode 100644 index 993a966e3..000000000 --- a/api/kubernetes/cli/client_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "testing" -) - -func TestClearUserClientCache(t *testing.T) { - factory, _ := NewClientFactory(nil, nil, nil, "", "", "") - kcl := &KubeClient{} - factory.endpointProxyClients.Set("12.1", kcl, 0) - factory.endpointProxyClients.Set("12.12", kcl, 0) - factory.endpointProxyClients.Set("12", kcl, 0) - - factory.ClearUserClientCache("12") - - if len(factory.endpointProxyClients.Items()) != 2 { - t.Errorf("Incorrect clients cached after clearUserClientCache;\ngot=\n%d\nwant=\n%d", len(factory.endpointProxyClients.Items()), 2) - } - if _, ok := factory.GetProxyKubeClient("12", "12"); ok { - t.Errorf("Expected not to find client cache for user after clear") - } -} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 560b91e75..11307d651 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "sort" "strconv" "time" @@ -352,34 +351,6 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, return namespace, nil } -// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces -func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) { - allEvents, err := kcl.GetEvents("", "") - if err != nil && !k8serrors.IsNotFound(err) { - log.Error(). - Str("context", "CombineNamespacesWithUnhealthyEvents"). - Err(err). - Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user") - return nil, err - } - - unhealthyEventCounts := make(map[string]int) - for _, event := range allEvents { - if event.Type == "Warning" { - unhealthyEventCounts[event.Namespace]++ - } - } - - for namespaceName, namespace := range namespaces { - if count, exists := unhealthyEventCounts[namespaceName]; exists { - namespace.UnhealthyEventCount = count - namespaces[namespaceName] = namespace - } - } - - return namespaces, nil -} - // CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { resourceQuotas, err := kcl.GetResourceQuotas("") @@ -438,10 +409,5 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine namespaceSlice = append(namespaceSlice, namespace) } - // Sort namespaces by name - sort.Slice(namespaceSlice, func(i, j int) bool { - return namespaceSlice[i].Name < namespaceSlice[j].Name - }) - return namespaceSlice } diff --git a/api/portainer.go b/api/portainer.go index 3ccda4107..b3b917a08 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -7,18 +7,17 @@ import ( "net/http" "time" - gittypes "github.com/portainer/portainer/api/git/types" - models "github.com/portainer/portainer/api/http/models/kubernetes" - "github.com/portainer/portainer/api/roar" - "github.com/portainer/portainer/pkg/featureflags" - httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/volume" + gittypes "github.com/portainer/portainer/api/git/types" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/pkg/featureflags" + httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/segmentio/encoding/json" + "golang.org/x/oauth2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/version" @@ -111,7 +110,6 @@ type ( AdminPassword *string AdminPasswordFile *string Assets *string - CSP *bool Data *string FeatureFlags *[]string EnableEdgeComputeFeatures *bool @@ -141,7 +139,6 @@ type ( LogMode *string KubectlShellImage *string PullLimitCheckDisabled *bool - TrustedOrigins *string } // CustomTemplateVariableDefinition @@ -216,34 +213,26 @@ type ( // DockerSnapshot represents a snapshot of a specific Docker environment(endpoint) at a specific time DockerSnapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - ContainerCount int `json:"ContainerCount"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - HealthyContainerCount int `json:"HealthyContainerCount"` - UnhealthyContainerCount int `json:"UnhealthyContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` - SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` - NodeCount int `json:"NodeCount"` - GpuUseAll bool `json:"GpuUseAll"` - GpuUseList []string `json:"GpuUseList"` - IsPodman bool `json:"IsPodman"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` - } - - // PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments - PerformanceMetrics struct { - CPUUsage float64 `json:"CPUUsage,omitempty"` - MemoryUsage float64 `json:"MemoryUsage,omitempty"` - NetworkUsage float64 `json:"NetworkUsage,omitempty"` + Time int64 `json:"Time"` + DockerVersion string `json:"DockerVersion"` + Swarm bool `json:"Swarm"` + TotalCPU int `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + ContainerCount int `json:"ContainerCount"` + RunningContainerCount int `json:"RunningContainerCount"` + StoppedContainerCount int `json:"StoppedContainerCount"` + HealthyContainerCount int `json:"HealthyContainerCount"` + UnhealthyContainerCount int `json:"UnhealthyContainerCount"` + VolumeCount int `json:"VolumeCount"` + ImageCount int `json:"ImageCount"` + ServiceCount int `json:"ServiceCount"` + StackCount int `json:"StackCount"` + SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` + NodeCount int `json:"NodeCount"` + GpuUseAll bool `json:"GpuUseAll"` + GpuUseList []string `json:"GpuUseList"` + IsPodman bool `json:"IsPodman"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` } // DockerContainerSnapshot is an extent of Docker's Container struct @@ -266,15 +255,12 @@ type ( // EdgeGroup represents an Edge group EdgeGroup struct { // EdgeGroup Identifier - ID EdgeGroupID `json:"Id" example:"1"` - Name string `json:"Name"` - Dynamic bool `json:"Dynamic"` - TagIDs []TagID `json:"TagIds"` - EndpointIDs roar.Roar[EndpointID] `json:"EndpointIds"` - PartialMatch bool `json:"PartialMatch"` - - // Deprecated: only used for API responses - Endpoints []EndpointID `json:"Endpoints"` + ID EdgeGroupID `json:"Id" example:"1"` + Name string `json:"Name"` + Dynamic bool `json:"Dynamic"` + TagIDs []TagID `json:"TagIds"` + Endpoints []EndpointID `json:"Endpoints"` + PartialMatch bool `json:"PartialMatch"` } // EdgeGroupID represents an Edge group identifier @@ -607,12 +593,6 @@ type ( ProjectPath string `json:"ProjectPath"` } - // GithubRegistryData represents data required for Github registry to work - GithubRegistryData struct { - UseOrganisation bool `json:"UseOrganisation"` - OrganisationName string `json:"OrganisationName"` - } - HelmUserRepositoryID int // HelmUserRepositories stores a Helm repository URL for the given user @@ -640,16 +620,15 @@ type ( JobType int K8sNamespaceInfo struct { - Id string `json:"Id"` - Name string `json:"Name"` - Status corev1.NamespaceStatus `json:"Status"` - Annotations map[string]string `json:"Annotations"` - CreationDate string `json:"CreationDate"` - UnhealthyEventCount int `json:"UnhealthyEventCount"` - NamespaceOwner string `json:"NamespaceOwner"` - IsSystem bool `json:"IsSystem"` - IsDefault bool `json:"IsDefault"` - ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + Annotations map[string]string `json:"Annotations"` + CreationDate string `json:"CreationDate"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` } K8sNodeLimits struct { @@ -681,13 +660,12 @@ type ( // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time KubernetesSnapshot struct { - Time int64 `json:"Time"` - KubernetesVersion string `json:"KubernetesVersion"` - NodeCount int `json:"NodeCount"` - TotalCPU int64 `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` + Time int64 `json:"Time"` + KubernetesVersion string `json:"KubernetesVersion"` + NodeCount int `json:"NodeCount"` + TotalCPU int64 `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` } // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) @@ -833,7 +811,6 @@ type ( Password string `json:"Password,omitempty" example:"registry_password"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` - Github GithubRegistryData `json:"Github"` Quay QuayRegistryData `json:"Quay"` Ecr EcrData `json:"Ecr"` RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` @@ -1542,42 +1519,10 @@ type ( // GitService represents a service for managing Git GitService interface { - CloneRepository( - destination string, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, - ) error - LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, - ) (string, error) - ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, - ) ([]string, error) - ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includeExts []string, - tlsSkipVerify bool, - ) ([]string, error) + CloneRepository(destination string, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error + LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) + ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) + ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includeExts []string, tlsSkipVerify bool) ([]string, error) } // OpenAMTService represents a service for managing OpenAMT @@ -1783,7 +1728,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.32.0" + APIVersion = "2.31.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 @@ -1842,10 +1787,6 @@ const ( LicenseServerBaseURL = "https://api.portainer.io" // URL to validate licenses along with system metadata. LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" - // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection - TrustedOriginsEnvVar = "TRUSTED_ORIGINS" - // CSPEnvVar is the environment variable used to enable/disable the Content Security Policy - CSPEnvVar = "CSP" ) // List of supported features @@ -2015,8 +1956,6 @@ const ( DockerHubRegistry // EcrRegistry represents an ECR registry EcrRegistry - // Github container registry - GithubRegistry ) const ( diff --git a/api/roar/roar.go b/api/roar/roar.go deleted file mode 100644 index 6edc67f75..000000000 --- a/api/roar/roar.go +++ /dev/null @@ -1,145 +0,0 @@ -package roar - -import ( - "fmt" - - "github.com/RoaringBitmap/roaring/v2" -) - -type Roar[T ~int] struct { - rb *roaring.Bitmap -} - -// Iterate iterates over the bitmap, calling the given callback with each value in the bitmap. If the callback returns -// false, the iteration is halted. -// The iteration results are undefined if the bitmap is modified (e.g., with Add or Remove). -// There is no guarantee as to what order the values will be iterated. -func (r *Roar[T]) Iterate(f func(T) bool) { - if r.rb == nil { - return - } - - r.rb.Iterate(func(e uint32) bool { - return f(T(e)) - }) -} - -// Len returns the number of elements contained in the bitmap -func (r *Roar[T]) Len() int { - if r.rb == nil { - return 0 - } - - return int(r.rb.GetCardinality()) -} - -// Remove removes the given element from the bitmap -func (r *Roar[T]) Remove(e T) { - if r.rb == nil { - return - } - - r.rb.Remove(uint32(e)) -} - -// Add adds the given element to the bitmap -func (r *Roar[T]) Add(e T) { - if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.AddInt(int(e)) -} - -// Contains returns whether the bitmap contains the given element or not -func (r *Roar[T]) Contains(e T) bool { - if r.rb == nil { - return false - } - - return r.rb.ContainsInt(int(e)) -} - -// Union combines the elements of the given bitmap with this bitmap -func (r *Roar[T]) Union(other Roar[T]) { - if other.rb == nil { - return - } else if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.Or(other.rb) -} - -// Intersection modifies this bitmap to only contain elements that are also in the other bitmap -func (r *Roar[T]) Intersection(other Roar[T]) { - if other.rb == nil { - if r.rb != nil { - r.rb.Clear() - } - - return - } - - if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.And(other.rb) -} - -// ToSlice converts the bitmap to a slice of elements -func (r *Roar[T]) ToSlice() []T { - if r.rb == nil { - return make([]T, 0) - } - - slice := make([]T, 0, r.rb.GetCardinality()) - r.rb.Iterate(func(e uint32) bool { - slice = append(slice, T(e)) - - return true - }) - - return slice -} - -func (r *Roar[T]) MarshalJSON() ([]byte, error) { - if r.rb == nil { - return []byte("null"), nil - } - - r.rb.RunOptimize() - - buf, err := r.rb.ToBase64() - if err != nil { - return nil, fmt.Errorf("failed to encode roaring bitmap: %w", err) - } - - return fmt.Appendf(nil, `"%s"`, buf), nil -} - -func (r *Roar[T]) UnmarshalJSON(data []byte) error { - if len(data) == 0 || string(data) == "null" { - return nil - } - - r.rb = roaring.New() - - _, err := r.rb.FromBase64(string(data[1 : len(data)-1])) - - return err -} - -// FromSlice creates a Roar by adding all elements from the provided slices -func FromSlice[T ~int](ess ...[]T) Roar[T] { - var r Roar[T] - - for _, es := range ess { - for _, e := range es { - r.Add(e) - } - } - - return r -} diff --git a/api/roar/roar_test.go b/api/roar/roar_test.go deleted file mode 100644 index ed5103ad5..000000000 --- a/api/roar/roar_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package roar - -import ( - "slices" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRoar(t *testing.T) { - r := Roar[int]{} - require.Equal(t, 0, r.Len()) - - r.Add(1) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(1)) - require.False(t, r.Contains(2)) - - r.Add(2) - require.Equal(t, 2, r.Len()) - require.True(t, r.Contains(2)) - - r.Remove(1) - require.Equal(t, 1, r.Len()) - require.False(t, r.Contains(1)) - - s := FromSlice([]int{3, 4, 5}) - require.Equal(t, 3, s.Len()) - require.True(t, s.Contains(3)) - require.True(t, s.Contains(4)) - require.True(t, s.Contains(5)) - - r.Union(s) - require.Equal(t, 4, r.Len()) - require.True(t, r.Contains(2)) - require.True(t, r.Contains(3)) - require.True(t, r.Contains(4)) - require.True(t, r.Contains(5)) - - r.Iterate(func(id int) bool { - require.True(t, slices.Contains([]int{2, 3, 4, 5}, id)) - - return true - }) - - rSlice := r.ToSlice() - require.EqualValues(t, []int{2, 3, 4, 5}, rSlice) - - r.Intersection(FromSlice([]int{4})) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(4)) - require.False(t, r.Contains(2)) - require.False(t, r.Contains(3)) - require.False(t, r.Contains(5)) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.NotEqual(t, "null", string(b)) - require.True(t, strings.HasPrefix(string(b), `"`)) - require.True(t, strings.HasSuffix(string(b), `"`)) -} - -func TestNilSafety(t *testing.T) { - var r, s, u Roar[int] - - r.Iterate(func(id int) bool { - require.Fail(t, "should not iterate over nil Roar") - - return true - }) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.Equal(t, "null", string(b)) - - err = r.UnmarshalJSON([]byte("null")) - require.NoError(t, err) - require.Equal(t, 0, r.Len()) - - r.Contains(1) - r.Remove(1) - - require.Equal(t, 0, r.Len()) - require.Empty(t, r.ToSlice()) - - r.Add(1) - require.Equal(t, 1, r.Len()) - require.False(t, r.Contains(2)) - - s.Union(r) - require.Equal(t, 1, s.Len()) - require.True(t, s.Contains(1)) - - r.Union(u) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(1)) - - s.Intersection(u) - require.Equal(t, 0, s.Len()) - - u.Intersection(r) - require.Equal(t, 0, u.Len()) -} - -func TestJSON(t *testing.T) { - var r, u Roar[int] - - r.Add(1) - r.Add(2) - r.Add(3) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.NotEqual(t, "null", string(b)) - - err = u.UnmarshalJSON(b) - require.NoError(t, err) - require.Equal(t, 3, u.Len()) - require.True(t, u.Contains(1)) - require.True(t, u.Contains(2)) - require.True(t, u.Contains(3)) -} diff --git a/api/slicesx/filter.go b/api/slicesx/filter.go deleted file mode 100644 index 13dc12105..000000000 --- a/api/slicesx/filter.go +++ /dev/null @@ -1,28 +0,0 @@ -package slicesx - -// Iterates over elements of collection, returning an array of all elements predicate returns truthy for. -// -// Note: Unlike `FilterInPlace`, this method returns a new array. -func Filter[T any](input []T, predicate func(T) bool) []T { - result := make([]T, 0) - for i := range input { - if predicate(input[i]) { - result = append(result, input[i]) - } - } - return result -} - -// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements. -// -// Note: Unlike `Filter`, this method mutates input. -func FilterInPlace[T any](input []T, predicate func(T) bool) []T { - n := 0 - for _, v := range input { - if predicate(v) { - input[n] = v - n++ - } - } - return input[:n] -} diff --git a/api/slicesx/filter_test.go b/api/slicesx/filter_test.go deleted file mode 100644 index 36f97fa10..000000000 --- a/api/slicesx/filter_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Filter(t *testing.T) { - test(t, slicesx.Filter, "Filter even numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{2, 4, 6, 8}, - func(x int) bool { return x%2 == 0 }, - ) - test(t, slicesx.Filter, "Filter odd numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{1, 3, 5, 7, 9}, - func(x int) bool { return x%2 == 1 }, - ) - test(t, slicesx.Filter, "Filter strings starting with 'A'", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Apple", "Avocado", "Apricot"}, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Filter, "Filter strings longer than 5 chars", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Banana", "Avocado", "Grapes", "Apricot"}, - func(s string) bool { return len(s) > 5 }, - ) -} - -func Test_Retain(t *testing.T) { - test(t, slicesx.FilterInPlace, "Filter even numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{2, 4, 6, 8}, - func(x int) bool { return x%2 == 0 }, - ) - test(t, slicesx.FilterInPlace, "Filter odd numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{1, 3, 5, 7, 9}, - func(x int) bool { return x%2 == 1 }, - ) - test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Apple", "Avocado", "Apricot"}, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Banana", "Avocado", "Grapes", "Apricot"}, - func(s string) bool { return len(s) > 5 }, - ) -} - -func Benchmark_Filter(b *testing.B) { - n := 100000 - - source := make([]int, n) - for i := range source { - source[i] = i - } - - b.ResetTimer() - for range b.N { - e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 }) - if len(e) != n/2 { - b.FailNow() - } - } -} - -func Benchmark_FilterInPlace(b *testing.B) { - n := 100000 - - source := make([]int, n) - for i := range source { - source[i] = i - } - - // Preallocate all copies before timing - // because FilterInPlace mutates the original slice - copies := make([][]int, b.N) - for i := range b.N { - buf := make([]int, len(source)) - copy(buf, source) - copies[i] = buf - } - - b.ResetTimer() - for i := range b.N { - e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 }) - if len(e) != n/2 { - b.FailNow() - } - } -} diff --git a/api/slicesx/flatten.go b/api/slicesx/flatten.go deleted file mode 100644 index 56a77f3e9..000000000 --- a/api/slicesx/flatten.go +++ /dev/null @@ -1,7 +0,0 @@ -package slicesx - -import "slices" - -func Flatten[T any](input [][]T) []T { - return slices.Concat(input...) -} diff --git a/api/slicesx/flatten_test.go b/api/slicesx/flatten_test.go deleted file mode 100644 index 6875c4e6b..000000000 --- a/api/slicesx/flatten_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" - "github.com/stretchr/testify/assert" -) - -func Test_Flatten(t *testing.T) { - t.Run("Flatten an array of arrays", func(t *testing.T) { - is := assert.New(t) - - source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} - expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} - is.ElementsMatch(slicesx.Flatten(source), expected) - - }) -} diff --git a/api/slicesx/includes.go b/api/slicesx/includes.go deleted file mode 100644 index 377a54215..000000000 --- a/api/slicesx/includes.go +++ /dev/null @@ -1,17 +0,0 @@ -package slicesx - -import "slices" - -// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy. -func Some[T any](input []T, predicate func(T) bool) bool { - return slices.ContainsFunc(input, predicate) -} - -// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey. -// -// Note: This method returns true for empty collections because everything is true of elements of empty collections. -// https://en.wikipedia.org/wiki/Vacuous_truth -func Every[T any](input []T, predicate func(T) bool) bool { - // if the slice doesn't contain an inverted predicate then all items follow the predicate - return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) }) -} diff --git a/api/slicesx/includes_test.go b/api/slicesx/includes_test.go deleted file mode 100644 index a3f074c1c..000000000 --- a/api/slicesx/includes_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Every(t *testing.T) { - test(t, slicesx.Every, "All start with an A (ok)", - []string{"Apple", "Avocado", "Apricot"}, - true, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Every, "All start with an A (ko = some don't start with A)", - []string{"Apple", "Avocado", "Banana"}, - false, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Every, "All are under 5 (ok)", - []int{1, 2, 3}, - true, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Every, "All are under 5 (ko = some above 10)", - []int{1, 2, 10}, - false, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Every, "All are true (ok)", - []struct{ x bool }{{x: true}, {x: true}, {x: true}}, - true, - func(s struct{ x bool }) bool { return s.x }) - test(t, slicesx.Every, "All are true (ko = some are false)", - []struct{ x bool }{{x: true}, {x: true}, {x: false}}, - false, - func(s struct{ x bool }) bool { return s.x }) - test(t, slicesx.Every, "Must be true on empty slice", - []int{}, - true, - func(i int) bool { return i%2 == 0 }, - ) -} - -func Test_Some(t *testing.T) { - test(t, slicesx.Some, "Some start with an A (ok)", - []string{"Apple", "Avocado", "Banana"}, - true, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)", - []string{"Banana", "Cherry", "Peach"}, - false, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Some, "Some are under 5 (ok)", - []int{1, 2, 30}, - true, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Some, "Some are under 5 (ko = all above 5)", - []int{10, 11, 12}, - false, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Some, "Some are true (ok)", - []struct{ x bool }{{x: true}, {x: true}, {x: false}}, - true, - func(s struct{ x bool }) bool { return s.x }, - ) - test(t, slicesx.Some, "Some are true (ko = all are false)", - []struct{ x bool }{{x: false}, {x: false}, {x: false}}, - false, - func(s struct{ x bool }) bool { return s.x }, - ) -} diff --git a/api/slicesx/map.go b/api/slicesx/map.go deleted file mode 100644 index 7e24bdd0d..000000000 --- a/api/slicesx/map.go +++ /dev/null @@ -1,15 +0,0 @@ -package slicesx - -// Map applies the given function to each element of the slice and returns a new slice with the results -func Map[T, U any](s []T, f func(T) U) []U { - result := make([]U, len(s)) - for i, v := range s { - result[i] = f(v) - } - return result -} - -// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results -func FlatMap[T, U any](s []T, f func(T) []U) []U { - return Flatten(Map(s, f)) -} diff --git a/api/slicesx/map_test.go b/api/slicesx/map_test.go deleted file mode 100644 index a2cd2256d..000000000 --- a/api/slicesx/map_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package slicesx_test - -import ( - "strconv" - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Map(t *testing.T) { - test(t, slicesx.Map, "Map integers to strings", - []int{1, 2, 3, 4, 5}, - []string{"1", "2", "3", "4", "5"}, - strconv.Itoa, - ) - test(t, slicesx.Map, "Map strings to integers", - []string{"1", "2", "3", "4", "5"}, - []int{1, 2, 3, 4, 5}, - func(s string) int { - n, _ := strconv.Atoi(s) - return n - }, - ) -} - -func Test_FlatMap(t *testing.T) { - test(t, slicesx.FlatMap, "Map integers to strings and flatten", - []int{1, 2, 3, 4, 5}, - []string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"}, - func(i int) []string { - x := strconv.Itoa(i) - return []string{x, x} - }, - ) - test(t, slicesx.FlatMap, "Map strings to integers and flatten", - []string{"1", "2", "3", "4", "5"}, - []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, - func(s string) []int { - n, _ := strconv.Atoi(s) - return []int{n, n} - }, - ) -} diff --git a/api/slicesx/unique.go b/api/slicesx/slices.go similarity index 51% rename from api/slicesx/unique.go rename to api/slicesx/slices.go index 8659b0778..b7e0aa0ef 100644 --- a/api/slicesx/unique.go +++ b/api/slicesx/slices.go @@ -1,5 +1,27 @@ package slicesx +// Map applies the given function to each element of the slice and returns a new slice with the results +func Map[T, U any](s []T, f func(T) U) []U { + result := make([]U, len(s)) + for i, v := range s { + result[i] = f(v) + } + return result +} + +// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true +func Filter[T any](s []T, predicate func(T) bool) []T { + n := 0 + for _, v := range s { + if predicate(v) { + s[n] = v + n++ + } + } + + return s[:n] +} + func Unique[T comparable](items []T) []T { return UniqueBy(items, func(item T) T { return item diff --git a/api/slicesx/slices_test.go b/api/slicesx/slices_test.go new file mode 100644 index 000000000..d75f9b559 --- /dev/null +++ b/api/slicesx/slices_test.go @@ -0,0 +1,127 @@ +package slicesx + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +type filterTestCase[T any] struct { + name string + input []T + expected []T + predicate func(T) bool +} + +func TestFilter(t *testing.T) { + intTestCases := []filterTestCase[int]{ + { + name: "Filter even numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{2, 4, 6, 8}, + + predicate: func(n int) bool { + return n%2 == 0 + }, + }, + { + name: "Filter odd numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{1, 3, 5, 7, 9}, + + predicate: func(n int) bool { + return n%2 != 0 + }, + }, + } + + runTestCases(t, intTestCases) + + stringTestCases := []filterTestCase[string]{ + { + name: "Filter strings starting with 'A'", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Apple", "Avocado", "Apricot"}, + predicate: func(s string) bool { + return s[0] == 'A' + }, + }, + { + name: "Filter strings longer than 5 characters", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Banana", "Avocado", "Grapes", "Apricot"}, + predicate: func(s string) bool { + return len(s) > 5 + }, + }, + } + + runTestCases(t, stringTestCases) +} + +func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Filter(testCase.input, testCase.predicate) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} + +func TestMap(t *testing.T) { + intTestCases := []struct { + name string + input []int + expected []string + mapper func(int) string + }{ + { + name: "Map integers to strings", + input: []int{1, 2, 3, 4, 5}, + expected: []string{"1", "2", "3", "4", "5"}, + mapper: strconv.Itoa, + }, + } + + runMapTestCases(t, intTestCases) + + stringTestCases := []struct { + name string + input []string + expected []int + mapper func(string) int + }{ + { + name: "Map strings to integers", + input: []string{"1", "2", "3", "4", "5"}, + expected: []int{1, 2, 3, 4, 5}, + mapper: func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + }, + } + + runMapTestCases(t, stringTestCases) +} + +func runMapTestCases[T, U any](t *testing.T, testCases []struct { + name string + input []T + expected []U + mapper func(T) U +}) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Map(testCase.input, testCase.mapper) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} diff --git a/api/slicesx/slicesx_test.go b/api/slicesx/slicesx_test.go deleted file mode 100644 index 1bb8a76fe..000000000 --- a/api/slicesx/slicesx_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package slicesx_test - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" -) - -type libFunc[T, U, V any] func([]T, func(T) U) V -type predicateFunc[T, U any] func(T) U - -func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) { - t.Helper() - - t.Run(name, func(t *testing.T) { - is := assert.New(t) - - result := libFn(input, predicate) - - switch reflect.TypeOf(result).Kind() { - case reflect.Slice, reflect.Array: - is.Equal(expected, result) - is.ElementsMatch(expected, result) - default: - is.Equal(expected, result) - } - }) -} diff --git a/api/slicesx/unique_test.go b/api/slicesx/unique_test.go deleted file mode 100644 index 8ff967ca6..000000000 --- a/api/slicesx/unique_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" - "github.com/stretchr/testify/assert" -) - -func Test_Unique(t *testing.T) { - is := assert.New(t) - t.Run("Should extract unique numbers", func(t *testing.T) { - - source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1} - result := slicesx.Unique(source) - expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} - - is.ElementsMatch(result, expected) - }) - - t.Run("Should return empty array", func(t *testing.T) { - source := []int{} - result := slicesx.Unique(source) - expected := []int{} - is.ElementsMatch(result, expected) - }) -} - -func Test_UniqueBy(t *testing.T) { - is := assert.New(t) - t.Run("Should extract unique numbers by property", func(t *testing.T) { - - source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}} - result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int }) - expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}} - - is.ElementsMatch(result, expected) - }) - - t.Run("Should return empty array", func(t *testing.T) { - source := []int{} - result := slicesx.UniqueBy(source, func(x int) int { return x }) - expected := []int{} - is.ElementsMatch(result, expected) - }) -} diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go index 566a2a2e2..d035b783d 100644 --- a/api/stacks/stackutils/gitops.go +++ b/api/stacks/stackutils/gitops.go @@ -19,23 +19,13 @@ var ( func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitService, getProjectPath func() string) (string, error) { username := "" password := "" - authType := gittypes.GitCredentialAuthType_Basic if config.Authentication != nil { username = config.Authentication.Username password = config.Authentication.Password - authType = config.Authentication.AuthorizationType } projectPath := getProjectPath() - err := gitService.CloneRepository( - projectPath, - config.URL, - config.ReferenceName, - username, - password, - authType, - config.TLSSkipVerify, - ) + err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password, config.TLSSkipVerify) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { newErr := git.ErrInvalidGitCredential @@ -46,14 +36,7 @@ func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitS return "", newErr } - commitID, err := gitService.LatestCommitID( - config.URL, - config.ReferenceName, - username, - password, - authType, - config.TLSSkipVerify, - ) + commitID, err := gitService.LatestCommitID(config.URL, config.ReferenceName, username, password, config.TLSSkipVerify) if err != nil { newErr := fmt.Errorf("unable to fetch git repository id: %w", err) return "", newErr diff --git a/app/assets/css/button.css b/app/assets/css/button.css index 547d9fdc0..0aece2a15 100644 --- a/app/assets/css/button.css +++ b/app/assets/css/button.css @@ -29,79 +29,43 @@ fieldset[disabled] .btn { } .btn.btn-primary { - @apply border-graphite-700 bg-graphite-700 text-mist-100; - @apply hover:border-graphite-700/90 hover:bg-graphite-700/90 hover:text-mist-100; - @apply focus:border-blue-5 focus:shadow-graphite-700/80 focus:text-mist-100; - - @apply th-dark:border-mist-100 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-dark:hover:border-mist-100/90 th-dark:hover:bg-mist-100/90 th-dark:hover:text-graphite-700; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-graphite-700; - - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; - @apply th-highcontrast:hover:border-mist-100/90 th-highcontrast:hover:bg-mist-100/90 th-highcontrast:hover:text-graphite-700; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-graphite-700; -} - -/* Sidebar background is always dark, so we need to override the primary button styles */ -.btn.btn-primary.sidebar { - @apply border-mist-100 bg-mist-100 text-graphite-700; - @apply hover:border-mist-100/90 hover:bg-mist-100/90 hover:text-graphite-700; - @apply focus:border-blue-5 focus:shadow-white/80 focus:text-graphite-700; + @apply border-blue-8 bg-blue-8 text-white; + @apply hover:border-blue-9 hover:bg-blue-9 hover:text-white; + @apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7; } .btn.btn-primary:active, .btn.btn-primary.active, .open > .dropdown-toggle.btn-primary { - @apply border-graphite-700/80 bg-graphite-700 text-mist-100; - @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply border-blue-5 bg-blue-9; } .nav-pills > li.active > a, .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { - @apply bg-graphite-700 text-mist-100; - @apply th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply bg-blue-8; } /* Button Secondary */ .btn.btn-secondary { @apply border border-solid; - @apply border-graphite-700 bg-mist-100 text-graphite-700; - @apply hover:border-graphite-700 hover:bg-graphite-700/10 hover:text-graphite-700; - @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-graphite-700; + @apply border-blue-8 bg-blue-2 text-blue-9; + @apply hover:bg-blue-3; - @apply th-dark:border-mist-100 th-dark:bg-graphite-700 th-dark:text-mist-100; - @apply th-dark:hover:border-mist-100 th-dark:hover:bg-mist-100/20 th-dark:hover:text-mist-100; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-mist-100; - - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-graphite-700 th-highcontrast:text-mist-100; - @apply th-highcontrast:hover:border-mist-100 th-highcontrast:hover:bg-mist-100/20 th-highcontrast:hover:text-mist-100; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-mist-100; -} - -.btn.btn-secondary:active, -.btn.btn-secondary.active, -.open > .dropdown-toggle.btn-secondary { - @apply border-graphite-700 bg-graphite-700/10 text-graphite-700; - @apply th-dark:border-mist-100 th-dark:bg-mist-100/20 th-dark:text-mist-100; - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100/20 th-highcontrast:text-mist-100; + @apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3; + @apply th-dark:hover:bg-blue-11; } .btn.btn-danger { @apply border-error-8 bg-error-8; @apply hover:border-error-7 hover:bg-error-7 hover:text-white; - @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-white; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-danger:active, .btn.btn-danger.active, .open > .dropdown-toggle.btn-danger { - @apply border-error-5 bg-error-8 text-white; + @apply border-blue-5 bg-error-8 text-white; } .btn.btn-dangerlight { @@ -110,10 +74,6 @@ fieldset[disabled] .btn { @apply hover:bg-error-2 th-dark:hover:bg-error-11; @apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7; @apply border border-solid; - - @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-error-9; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80; } .btn.btn-icon.btn-dangerlight { @apply hover:text-error-11 th-dark:hover:text-error-7; @@ -130,18 +90,15 @@ fieldset[disabled] .btn { /* secondary-grey */ .btn.btn-default, .btn.btn-light { - @apply border-gray-5 bg-white text-gray-8; - @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10; - @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-gray-8; + @apply border-gray-5 bg-white text-gray-7; + @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-9; /* dark mode */ @apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4; @apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-gray-warm-4; @apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white; @apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-light:active, @@ -162,17 +119,42 @@ fieldset[disabled] .btn { .input-group-btn .btn.active, .btn-group .btn.active { - @apply border-graphite-700/80 bg-graphite-700 text-mist-100; - @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply border-blue-5 bg-blue-2 text-blue-10; + @apply th-dark:border-blue-9 th-dark:bg-blue-11 th-dark:text-blue-2; +} + +/* focus */ + +.btn.btn-primary:focus, +.btn.btn-secondary:focus, +.btn.btn-light:focus { + @apply border-blue-5; +} + +.btn.btn-danger:focus, +.btn.btn-dangerlight:focus { + @apply border-blue-6; +} + +.btn.btn-primary:focus, +.btn.btn-secondary:focus, +.btn.btn-light:focus, +.btn.btn-danger:focus, +.btn.btn-dangerlight:focus { + --btn-focus-color: var(--ui-blue-3); + box-shadow: 0px 0px 0px 4px var(--btn-focus-color); } .btn.btn-icon:focus { box-shadow: none !important; } -.btn:focus { - box-shadow: 0px 0px 0px 2px var(--tw-shadow-color); +[theme='dark'] .btn.btn-primary:focus, +[theme='dark'] .btn.btn-secondary:focus, +[theme='dark'] .btn.btn-light:focus, +[theme='dark'] .btn.btn-danger:focus, +[theme='dark'] .btn.btn-dangerlight:focus { + --btn-focus-color: var(--ui-blue-11); } a.no-link, diff --git a/app/assets/css/colors.json b/app/assets/css/colors.json index 94d3c2015..55f2922e5 100644 --- a/app/assets/css/colors.json +++ b/app/assets/css/colors.json @@ -1,31 +1,6 @@ { "black": "#000000", "white": "#ffffff", - "graphite": { - "10": "#f5f5f6", - "50": "#e5e6e8", - "100": "#ced0d3", - "200": "#abafb5", - "300": "#7b8089", - "400": "#5c6066", - "500": "#484a4e", - "600": "#3a3b3f", - "700": "#2e2f33", - "800": "#222326", - "900": "#161719" - }, - "mist": { - "50": "#fcfbfa", - "100": "#f7f6f3", - "200": "#f0f0ec", - "300": "#e8e7e2", - "400": "#e2e1db", - "500": "#d9d8d2", - "600": "#ceccc4", - "700": "#bebcb4", - "800": "#a7a6a0", - "900": "#8b8983" - }, "gray": { "1": "#fcfcfd", "2": "#f9fafb", diff --git a/app/assets/css/react-datetime-picker-override.css b/app/assets/css/react-datetime-picker-override.css index dbbea4766..acd26fb58 100644 --- a/app/assets/css/react-datetime-picker-override.css +++ b/app/assets/css/react-datetime-picker-override.css @@ -12,40 +12,35 @@ /* Extending Calendar.css from react-daterange-picker__calendar */ -.react-calendar { +.react-daterange-picker__calendar .react-calendar { background: var(--bg-calendar-color); color: var(--text-main-color); - @apply th-dark:bg-gray-iron-10; } /* calendar nav buttons */ -.react-calendar__navigation button:disabled { +.react-daterange-picker__calendar .react-calendar__navigation button:disabled { background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; - @apply th-dark:bg-gray-iron-7; } -.react-calendar__navigation button:enabled:hover, -.react-calendar__navigation button:enabled:focus { +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover, +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus { background: var(--bg-daterangepicker-color); - @apply th-dark:bg-gray-iron-7; } /* date tile */ -.react-calendar__tile:disabled { +.react-daterange-picker__calendar .react-calendar__tile:disabled { + background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; - @apply th-dark:bg-gray-iron-7; } - -.react-calendar__tile:enabled:hover, -.react-calendar__tile:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile:enabled:focus { background: var(--bg-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } /* today's date tile */ -.react-calendar__tile--now { +.react-daterange-picker__calendar .react-calendar__tile--now { @apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)]; border-radius: 0.25rem !important; } @@ -53,27 +48,23 @@ .react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } /* probably date tile in range */ -.react-calendar__tile--hasActive { +.react-daterange-picker__calendar .react-calendar__tile--hasActive { background: var(--bg-daterangepicker-end-date); color: var(--text-daterangepicker-end-date); - @apply th-dark:bg-gray-iron-7; } -.react-calendar__tile--hasActive:enabled:hover, -.react-calendar__tile--hasActive:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } -.react-calendar__tile--active:enabled:hover, -.react-calendar__tile--active:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } .react-daterange-picker__calendar @@ -84,10 +75,9 @@ } /* on range select hover */ -.react-calendar--selectRange .react-calendar__tile--hover { +.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover { background: var(--bg-daterangepicker-in-range); color: var(--text-daterangepicker-in-range); - @apply th-dark:bg-gray-iron-7; } /* @@ -121,5 +111,4 @@ .react-calendar__tile--active.react-calendar__month-view__days__day--weekend { color: var(--text-daterangepicker-active); - @apply th-dark:bg-gray-iron-7; } diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 318e0d9e4..eb2d36882 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -3,16 +3,6 @@ --black-color: var(--ui-black); --white-color: var(--ui-white); - --graphite-600: #3a3b3f; - --graphite-700: #2e2f33; - --graphite-800: #222326; - --graphite-900: #161719; - - --mist-50: #fcfbfa; - --mist-100: #f7f6f3; - --mist-200: #f0f0ec; - --mist-300: #e8e7e2; - --grey-1: #212121; --grey-2: #181818; --grey-3: #383838; @@ -68,8 +58,6 @@ --grey-58: #ebf4f8; --grey-59: #e6e6e6; --grey-61: rgb(231, 231, 231); - --grey-62: #fdfdfd; - --grey-63: #121212; --blue-1: #219; --blue-2: #337ab7; @@ -111,16 +99,17 @@ /* Default Theme */ --bg-card-color: var(--white-color); --bg-main-color: var(--white-color); - --bg-body-color: var(--grey-62); + --bg-body-color: var(--grey-9); --bg-checkbox-border-color: var(--grey-49); - --bg-sidebar-color: var(--graphite-700); - --bg-sidebar-nav-color: var(--graphite-600); + --bg-sidebar-color: var(--ui-blue-10); + --bg-sidebar-nav-color: var(--ui-blue-11); --bg-widget-color: var(--white-color); --bg-widget-header-color: var(--grey-10); --bg-widget-table-color: var(--ui-gray-3); --bg-header-color: var(--white-color); --bg-hover-table-color: var(--grey-14); --bg-input-group-addon-color: var(--ui-gray-3); + --bg-btn-default-color: var(--ui-blue-10); --bg-blocklist-hover-color: var(--ui-blue-2); --bg-table-color: var(--white-color); --bg-md-checkbox-color: var(--grey-12); @@ -139,8 +128,7 @@ --border-pagination-color: var(--ui-white); --bg-pagination-span-color: var(--white-color); --bg-pagination-hover-color: var(--ui-blue-3); - --bg-motd-body-color: var(--mist-50); - --bg-motd-btn-color: var(--graphite-700); + --bg-motd-body-color: var(--grey-20); --bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-null-color: var(--grey-14); --bg-panel-body-color: var(--white-color); @@ -156,6 +144,8 @@ --bg-daterangepicker-in-range: var(--grey-58); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); + --bg-btn-default-hover-color: var(--ui-blue-9); + --bg-btn-focus: var(--grey-59); --bg-small-select-color: var(--white-color); --bg-stepper-item-active: var(--white-color); --bg-stepper-item-counter: var(--grey-61); @@ -187,6 +177,7 @@ --text-navtabs-color: var(--grey-7); --text-navtabs-hover-color: var(--grey-6); --text-nav-tab-active-color: var(--grey-25); + --text-dropdown-menu-color: var(--grey-6); --text-log-viewer-color: var(--black-color); --text-json-tree-color: var(--blue-3); @@ -198,8 +189,6 @@ --text-pagination-color: var(--grey-26); --text-pagination-span-color: var(--grey-3); --text-pagination-span-hover-color: var(--grey-3); - --text-motd-body-color: var(--black-color); - --text-motd-btn-color: var(--mist-100); --text-summary-color: var(--black-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--grey-36); @@ -214,7 +203,6 @@ --text-button-group-color: var(--ui-gray-9); --text-button-dangerlight-color: var(--ui-error-5); --text-stepper-active-color: var(--ui-blue-8); - --border-color: var(--grey-42); --border-widget-color: var(--grey-43); --border-sidebar-color: var(--ui-blue-9); @@ -230,8 +218,7 @@ --border-pre-color: var(--grey-43); --border-pagination-span-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white); - --border-motd-body-color: var(--mist-300); - --border-panel-color: var(--mist-300); + --border-panel-color: var(--white-color); --border-input-sm-color: var(--grey-47); --border-daterangepicker-color: var(--grey-19); --border-calendar-table: var(--white-color); @@ -278,7 +265,8 @@ --text-log-viewer-color-json-red: var(--text-log-viewer-color); --text-log-viewer-color-json-blue: var(--text-log-viewer-color); - --bg-body-color: var(--grey-63); + --bg-body-color: var(--grey-2); + --bg-btn-default-color: var(--grey-3); --bg-blocklist-hover-color: var(--ui-gray-iron-10); --bg-blocklist-item-selected-color: var(--ui-gray-iron-10); --bg-card-color: var(--grey-1); @@ -286,6 +274,8 @@ --bg-code-color: var(--grey-2); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-main-color: var(--grey-2); + --bg-sidebar-color: var(--grey-1); + --bg-sidebar-nav-color: var(--grey-2); --bg-widget-color: var(--grey-1); --bg-widget-header-color: var(--grey-3); --bg-widget-table-color: var(--grey-3); @@ -306,8 +296,7 @@ --bg-pagination-color: var(--grey-3); --bg-pagination-span-color: var(--grey-1); --bg-pagination-hover-color: var(--grey-3); - --bg-motd-body-color: var(--graphite-800); - --bg-motd-btn-color: var(--mist-100); + --bg-motd-body-color: var(--grey-1); --bg-item-highlighted-color: var(--grey-2); --bg-item-highlighted-null-color: var(--grey-2); --bg-panel-body-color: var(--grey-1); @@ -327,6 +316,8 @@ --bg-daterangepicker-in-range: var(--ui-gray-warm-11); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); + --bg-btn-default-hover-color: var(--grey-4); + --bg-btn-focus: var(--grey-3); --bg-small-select-color: var(--grey-2); --bg-stepper-item-active: var(--grey-1); --bg-stepper-item-counter: var(--grey-7); @@ -357,6 +348,7 @@ --text-navtabs-color: var(--grey-8); --text-navtabs-hover-color: var(--grey-9); --text-nav-tab-active-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-json-tree-color: var(--grey-40); @@ -368,8 +360,6 @@ --text-pagination-color: var(--white-color); --text-pagination-span-color: var(--ui-white); --text-pagination-span-hover-color: var(--ui-white); - --text-motd-body-color: var(--mist-100); - --text-motd-btn-color: var(--graphite-700); --text-summary-color: var(--white-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--white-color); @@ -384,7 +374,6 @@ --text-button-group-color: var(--ui-white); --text-button-dangerlight-color: var(--ui-error-7); --text-stepper-active-color: var(--ui-white); - --border-color: var(--grey-3); --border-widget-color: var(--grey-1); --border-sidebar-color: var(--ui-gray-8); @@ -402,7 +391,6 @@ --border-blocklist-item-selected-color: var(--grey-31); --border-pagination-span-color: var(--grey-1); --border-pagination-hover-color: var(--grey-3); - --border-motd-body-color: var(--graphite-800); --border-panel-color: var(--grey-2); --border-input-sm-color: var(--grey-3); --border-daterangepicker-color: var(--grey-3); @@ -462,7 +450,6 @@ --bg-panel-body-color: var(--black-color); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-motd-body-color: var(--black-color); - --bg-motd-btn-color: var(--white-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); --bg-input-group-addon-color: var(--grey-3); @@ -494,8 +481,11 @@ --bg-navtabs-hover-color: var(--grey-3); --bg-nav-tab-active-color: var(--ui-black); + --bg-btn-default-color: var(--black-color); --bg-input-autofill-color: var(--bg-inputbox); --bg-code-color: var(--ui-black); + --bg-btn-default-hover-color: var(--grey-4); + --bg-btn-focus: var(--black-color); --bg-small-select-color: var(--black-color); --bg-stepper-item-active: var(--black-color); --bg-stepper-item-counter: var(--grey-3); @@ -533,8 +523,6 @@ --text-daterangepicker-end-date: var(--ui-white); --text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-active: var(--white-color); - --text-motd-body-color: var(--white-color); - --text-motd-btn-color: var(--black-color); --text-json-tree-color: var(--white-color); --text-json-tree-leaf-color: var(--white-color); --text-json-tree-branch-preview-color: var(--white-color); @@ -565,7 +553,6 @@ --border-input-sm-color: var(--white-color); --border-pagination-color: var(--grey-1); --border-pagination-span-color: var(--grey-1); - --border-motd-body-color: var(--white-color); --border-daterangepicker-color: var(--white-color); --border-calendar-table: var(--black-color); --border-daterangepicker: var(--black-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 12e0fc947..74fa94d4e 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -201,18 +201,8 @@ pre { background-color: var(--bg-progress-color); } -.widget-body.motd-body { - border: 1px solid var(--border-motd-body-color); - color: var(--text-motd-body-color); - background: var(--bg-motd-body-color) url(../images/purple-gradient.svg) top right / 40% no-repeat; -} - -.widget-body.motd-body .btn.btn-link, -.widget-body.motd-body .btn.btn-link:hover { - padding: 0 5px 0 4px; - border-radius: 4px; - background-color: var(--bg-motd-btn-color); - color: var(--text-motd-btn-color); +.motd-body { + background-color: var(--bg-motd-body-color) !important; } .panel-body { @@ -418,10 +408,14 @@ input:-webkit-autofill { } .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { - border-right: 8px solid var(--graphite-600); + border-right: 8px solid var(--ui-blue-9); border-width: 6px 8px 6px 0; } +[theme='dark'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--ui-gray-true-9); +} + [theme='highcontrast'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { border-right: 8px solid var(--ui-white); } diff --git a/app/assets/ico/android-chrome-192x192.png b/app/assets/ico/android-chrome-192x192.png index 236db0e2b..8f31e405a 100644 Binary files a/app/assets/ico/android-chrome-192x192.png and b/app/assets/ico/android-chrome-192x192.png differ diff --git a/app/assets/ico/android-chrome-256x256.png b/app/assets/ico/android-chrome-256x256.png index 52848e019..cc95d0044 100644 Binary files a/app/assets/ico/android-chrome-256x256.png and b/app/assets/ico/android-chrome-256x256.png differ diff --git a/app/assets/ico/apple-touch-icon.png b/app/assets/ico/apple-touch-icon.png index f05e9c161..aeea31ce8 100644 Binary files a/app/assets/ico/apple-touch-icon.png and b/app/assets/ico/apple-touch-icon.png differ diff --git a/app/assets/ico/favicon-16x16.png b/app/assets/ico/favicon-16x16.png index 8c60e5d9f..f7a26b564 100644 Binary files a/app/assets/ico/favicon-16x16.png and b/app/assets/ico/favicon-16x16.png differ diff --git a/app/assets/ico/favicon-32x32.png b/app/assets/ico/favicon-32x32.png index 8735718a2..d1ccc9cea 100644 Binary files a/app/assets/ico/favicon-32x32.png and b/app/assets/ico/favicon-32x32.png differ diff --git a/app/assets/ico/favicon.ico b/app/assets/ico/favicon.ico index 066969400..28ed661f9 100644 Binary files a/app/assets/ico/favicon.ico and b/app/assets/ico/favicon.ico differ diff --git a/app/assets/ico/logomark.svg b/app/assets/ico/logomark.svg index 140c1b494..b7679d482 100644 --- a/app/assets/ico/logomark.svg +++ b/app/assets/ico/logomark.svg @@ -1,12 +1,35 @@ - - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + diff --git a/app/assets/ico/mstile-150x150.png b/app/assets/ico/mstile-150x150.png index f48374538..5e7eb6873 100644 Binary files a/app/assets/ico/mstile-150x150.png and b/app/assets/ico/mstile-150x150.png differ diff --git a/app/assets/ico/safari-pinned-tab.svg b/app/assets/ico/safari-pinned-tab.svg index d0509a572..79ce7b6fa 100644 --- a/app/assets/ico/safari-pinned-tab.svg +++ b/app/assets/ico/safari-pinned-tab.svg @@ -1,6 +1 @@ - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 000000000..2e46594f2 Binary files /dev/null and b/app/assets/images/logo.png differ diff --git a/app/assets/images/logo_alt.png b/app/assets/images/logo_alt.png new file mode 100644 index 000000000..a6c6707ca Binary files /dev/null and b/app/assets/images/logo_alt.png differ diff --git a/app/assets/images/logo_alt.svg b/app/assets/images/logo_alt.svg index 8d254e4e5..90e164ca1 100644 --- a/app/assets/images/logo_alt.svg +++ b/app/assets/images/logo_alt.svg @@ -1,14 +1,60 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/logo_alt_black.svg b/app/assets/images/logo_alt_black.svg deleted file mode 100644 index d9243b464..000000000 --- a/app/assets/images/logo_alt_black.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/assets/images/logo_ico.png b/app/assets/images/logo_ico.png new file mode 100644 index 000000000..b4bfd2924 Binary files /dev/null and b/app/assets/images/logo_ico.png differ diff --git a/app/assets/images/logo_small.png b/app/assets/images/logo_small.png new file mode 100644 index 000000000..76d3a46b0 Binary files /dev/null and b/app/assets/images/logo_small.png differ diff --git a/app/assets/images/logo_small_alt.png b/app/assets/images/logo_small_alt.png new file mode 100644 index 000000000..a5bc64771 Binary files /dev/null and b/app/assets/images/logo_small_alt.png differ diff --git a/app/assets/images/purple-gradient.svg b/app/assets/images/purple-gradient.svg deleted file mode 100644 index 0b3bc7160..000000000 --- a/app/assets/images/purple-gradient.svg +++ /dev/null @@ -1,522 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index af37cd0e2..7bee83a2b 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -16,19 +16,19 @@ - + - + - + diff --git a/app/index.html b/app/index.html index 52b9b5d10..370070b48 100644 --- a/app/index.html +++ b/app/index.html @@ -20,7 +20,7 @@ - + @@ -47,10 +47,7 @@
-
- - -
+
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index cfb103823..27aa04444 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -92,7 +92,6 @@ export const ngModule = angular 'onChange', 'placeholder', 'value', - 'allowSelectAll', ]) ) .component( diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html index 5c5e68255..11184ae0f 100644 --- a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html @@ -19,7 +19,6 @@ namespaces="$ctrl.resourcePools" placeholder="'Select one or more namespaces'" on-change="($ctrl.onChangeResourcePools)" - allow-select-all="true" >
diff --git a/app/kubernetes/views/applications/logs/logsController.js b/app/kubernetes/views/applications/logs/logsController.js index 37cae6cad..66601d98b 100644 --- a/app/kubernetes/views/applications/logs/logsController.js +++ b/app/kubernetes/views/applications/logs/logsController.js @@ -77,7 +77,6 @@ class KubernetesApplicationLogsController { await this.getApplicationLogsAsync(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); - this.stopRepeater(); } finally { this.state.viewReady = true; } diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 60e7b0144..d57d0caa7 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -40,15 +40,17 @@
- +
- + data-cy="namespace-select" + ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" + class="form-control" + ng-model="ctrl.formValues.Namespace" + ng-change="ctrl.onChangeNamespace()" + ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces" + > Namespaces specified in the manifest will be used @@ -184,6 +186,7 @@
+
Selected Helm chart
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index b44d3d7bb..89f416ac3 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -101,10 +101,9 @@ class KubernetesDeployController { this.onChangeNamespace = this.onChangeNamespace.bind(this); } - onChangeNamespace(namespaceName) { + onChangeNamespace() { return this.$async(async () => { - this.formValues.Namespace = namespaceName; - const applications = await this.KubernetesApplicationService.get(namespaceName); + const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace); const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== ''); this.stacks = _.uniq(stacks); }); @@ -372,10 +371,6 @@ class KubernetesDeployController { if (this.namespaces.length > 0) { this.formValues.Namespace = this.namespaces[0].Name; } - this.namespaceOptions = _.map(namespaces, (namespace) => ({ - label: namespace.Name, - value: namespace.Name, - })); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load namespaces data'); } @@ -409,8 +404,7 @@ class KubernetesDeployController { } } - this.onChangeNamespace(this.formValues.Namespace); - + this.onChangeNamespace(); this.state.viewReady = true; this.$window.onbeforeunload = () => { diff --git a/app/kubernetes/views/stacks/logs/logsController.js b/app/kubernetes/views/stacks/logs/logsController.js index d4e1b5ba7..536ea2ae4 100644 --- a/app/kubernetes/views/stacks/logs/logsController.js +++ b/app/kubernetes/views/stacks/logs/logsController.js @@ -104,7 +104,6 @@ class KubernetesStackLogsController { await this.getStackLogsAsync(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve stack logs'); - this.stopRepeater(); } finally { this.state.viewReady = true; } diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 9b2f7325d..4b1c03608 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -9,7 +9,6 @@ import { withFormValidation } from '@/react-tools/withFormValidation'; import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable'; import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector'; import { withControlledInput } from '@/react-tools/withControlledInput'; -import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector'; import { EnvironmentVariablesFieldset, @@ -200,22 +199,11 @@ export const ngModule = angular 'onChange', 'options', 'isMulti', - 'filterOption', 'isClearable', 'components', 'isLoading', 'noOptionsMessage', 'aria-label', - 'loadingMessage', - ]) - ) - .component( - 'namespacePortainerSelect', - r2a(NamespacePortainerSelect, [ - 'value', - 'onChange', - 'isDisabled', - 'options', ]) ) .component( diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 2504141a7..09dadf050 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -4,10 +4,7 @@
-
- - -
+
diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index afff165b2..b5cfcfeb4 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -5,10 +5,7 @@
-
- - -
+
diff --git a/app/portainer/views/logout/logout.html b/app/portainer/views/logout/logout.html index 95299d5d0..fe9b2513d 100644 --- a/app/portainer/views/logout/logout.html +++ b/app/portainer/views/logout/logout.html @@ -4,10 +4,7 @@
-
- - -
+
diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index d9c7d273c..20fe7dee3 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -10,7 +10,7 @@ import { export function createMockUsers( count: number, - roles: Role | Role[] | ((id: UserId) => Role) + roles: Role | Role[] | ((id: UserId) => Role) = () => _.random(1, 3) ): User[] { return _.range(1, count + 1).map((value) => ({ Id: value, @@ -40,14 +40,7 @@ function getRoles( return roles; } - // Roles is an array - if (roles.length === 0) { - throw new Error('No roles provided'); - } - - // The number of roles is not necessarily the same length as the number of users - // so we need to distribute the roles evenly and consistently - return roles[(id - 1) % roles.length]; + return roles[id]; } export function createMockTeams(count: number): Team[] { diff --git a/app/react/components/CodeEditor/CodeEditor.test.tsx b/app/react/components/CodeEditor/CodeEditor.test.tsx index a269cd192..7b100b0e3 100644 --- a/app/react/components/CodeEditor/CodeEditor.test.tsx +++ b/app/react/components/CodeEditor/CodeEditor.test.tsx @@ -122,7 +122,7 @@ test('should apply custom height', async () => { ); - const editor = await findByRole('textbox'); + const editor = (await findByRole('textbox')).parentElement?.parentElement; expect(editor).toHaveStyle({ height: customHeight }); }); diff --git a/app/react/components/CodeEditor/useCodeEditorExtensions.ts b/app/react/components/CodeEditor/useCodeEditorExtensions.ts index 8050b59da..3b46a543e 100644 --- a/app/react/components/CodeEditor/useCodeEditorExtensions.ts +++ b/app/react/components/CodeEditor/useCodeEditorExtensions.ts @@ -48,7 +48,7 @@ function yamlLanguage(schema?: JSONSchema7) { syntaxHighlighting(oneDarkHighlightStyle), // explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor lineNumbers(), - !!schema && lintGutter(), + lintGutter(), keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]), // only show completions when a schema is provided !!schema && diff --git a/app/react/components/ExternalLink.tsx b/app/react/components/ExternalLink.tsx index 1bd839cad..ef16dcb66 100644 --- a/app/react/components/ExternalLink.tsx +++ b/app/react/components/ExternalLink.tsx @@ -1,20 +1,20 @@ -import { ArrowUpRight } from 'lucide-react'; +import { ExternalLink as ExternalLinkIcon } from 'lucide-react'; import { PropsWithChildren } from 'react'; import clsx from 'clsx'; import { AutomationTestingProps } from '@/types'; +import { Icon } from '@@/Icon'; + interface Props { to: string; className?: string; - showIcon?: boolean; } export function ExternalLink({ to, className, children, - showIcon = true, 'data-cy': dataCy, }: PropsWithChildren) { return ( @@ -23,10 +23,10 @@ export function ExternalLink({ target="_blank" rel="noreferrer" data-cy={dataCy} - className={clsx('inline-flex align-baseline', className)} + className={clsx('inline-flex items-center gap-1', className)} > - {children} - {showIcon && } + + {children} ); } diff --git a/app/react/components/FallbackImage.tsx b/app/react/components/FallbackImage.tsx index eaa4f1272..ee6956f24 100644 --- a/app/react/components/FallbackImage.tsx +++ b/app/react/components/FallbackImage.tsx @@ -27,5 +27,5 @@ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) { } // fallback icon if there is an error loading the image - return
{fallbackIcon}
; + return <>{fallbackIcon}; } diff --git a/app/react/components/InformationPanel.tsx b/app/react/components/InformationPanel.tsx index f25afadba..b5c9dafc9 100644 --- a/app/react/components/InformationPanel.tsx +++ b/app/react/components/InformationPanel.tsx @@ -19,7 +19,7 @@ export function InformationPanel({ children, }: PropsWithChildren) { return ( - +
{title && ( diff --git a/app/react/components/ViewLoading/ViewLoading.tsx b/app/react/components/ViewLoading/ViewLoading.tsx index 139c67637..5ade961bb 100644 --- a/app/react/components/ViewLoading/ViewLoading.tsx +++ b/app/react/components/ViewLoading/ViewLoading.tsx @@ -1,4 +1,7 @@ import clsx from 'clsx'; +import { Settings } from 'lucide-react'; + +import { Icon } from '@@/Icon'; import styles from './ViewLoading.module.css'; @@ -15,7 +18,12 @@ export function ViewLoading({ message }: Props) {
- {message && {message}} + {message && ( + + {message} + + + )}
); } diff --git a/app/react/components/datatables/index.ts b/app/react/components/datatables/index.ts index 3ab420889..809efc23b 100644 --- a/app/react/components/datatables/index.ts +++ b/app/react/components/datatables/index.ts @@ -11,4 +11,3 @@ export { TableHeaderRow } from './TableHeaderRow'; export { TableRow } from './TableRow'; export { TableContent } from './TableContent'; export { TableFooter } from './TableFooter'; -export { TableSettingsMenuAutoRefresh } from './TableSettingsMenuAutoRefresh'; diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx index 51dbab534..6ea747762 100644 --- a/app/react/components/form-components/FormSection/FormSection.tsx +++ b/app/react/components/form-components/FormSection/FormSection.tsx @@ -12,7 +12,6 @@ interface Props { titleClassName?: string; className?: string; htmlFor?: string; - setIsDefaultFolded?: (isDefaultFolded: boolean) => void; } export function FormSection({ @@ -24,7 +23,6 @@ export function FormSection({ titleClassName, className, htmlFor = '', - setIsDefaultFolded, }: PropsWithChildren) { const [isExpanded, setIsExpanded] = useState(!defaultFolded); const id = `foldingButton${title}`; @@ -41,10 +39,7 @@ export function FormSection({ isExpanded={isExpanded} data-cy={id} id={id} - onClick={() => { - setIsExpanded((isExpanded) => !isExpanded); - setIsDefaultFolded?.(isExpanded); - }} + onClick={() => setIsExpanded((isExpanded) => !isExpanded)} /> )} diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index 6800d0013..9ddf234da 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -5,25 +5,15 @@ import { } from 'react-select'; import _ from 'lodash'; import { AriaAttributes } from 'react'; -import { FilterOptionOption } from 'react-select/dist/declarations/src/filters'; import { AutomationTestingProps } from '@/types'; -import { - Creatable, - Select as ReactSelect, -} from '@@/form-components/ReactSelect'; +import { Select as ReactSelect } from '@@/form-components/ReactSelect'; export interface Option { value: TValue; label: string; disabled?: boolean; - [key: string]: unknown; -} - -export interface GroupOption { - label: string; - options: Option[]; } type Options = OptionsOrGroups< @@ -31,7 +21,7 @@ type Options = OptionsOrGroups< GroupBase> >; -interface SharedProps +interface SharedProps extends AutomationTestingProps, Pick { name?: string; @@ -42,14 +32,9 @@ interface SharedProps bindToBody?: boolean; isLoading?: boolean; noOptionsMessage?: () => string; - loadingMessage?: () => string; - filterOption?: ( - option: FilterOptionOption>, - rawInput: string - ) => boolean; } -interface MultiProps extends SharedProps { +interface MultiProps extends SharedProps { value: readonly TValue[]; onChange(value: TValue[]): void; options: Options; @@ -59,12 +44,9 @@ interface MultiProps extends SharedProps { true, GroupBase> >; - formatCreateLabel?: (input: string) => string; - onCreateOption?: (input: string) => void; - isCreatable?: boolean; } -interface SingleProps extends SharedProps { +interface SingleProps extends SharedProps { value: TValue; onChange(value: TValue | null): void; options: Options; @@ -76,13 +58,9 @@ interface SingleProps extends SharedProps { >; } -export type PortainerSelectProps = - | MultiProps - | SingleProps; +type Props = MultiProps | SingleProps; -export function PortainerSelect( - props: PortainerSelectProps -) { +export function PortainerSelect(props: Props) { return isMultiProps(props) ? ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -93,7 +71,7 @@ export function PortainerSelect( } function isMultiProps( - props: PortainerSelectProps + props: Props ): props is MultiProps { return 'isMulti' in props && !!props.isMulti; } @@ -109,11 +87,9 @@ export function SingleSelect({ placeholder, isClearable, bindToBody, - filterOption, components, isLoading, noOptionsMessage, - loadingMessage, isMulti, ...aria }: SingleProps) { @@ -140,11 +116,9 @@ export function SingleSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} - filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} - loadingMessage={loadingMessage} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> @@ -185,20 +159,14 @@ export function MultiSelect({ disabled, isClearable, bindToBody, - filterOption, components, isLoading, noOptionsMessage, - loadingMessage, - formatCreateLabel, - onCreateOption, - isCreatable, ...aria }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); - const SelectComponent = isCreatable ? Creatable : ReactSelect; return ( - ({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} - filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} - loadingMessage={loadingMessage} - formatCreateLabel={formatCreateLabel} - onCreateOption={onCreateOption} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> diff --git a/app/react/components/form-components/ReactSelect.tsx b/app/react/components/form-components/ReactSelect.tsx index c7ea47366..a9f4e6282 100644 --- a/app/react/components/form-components/ReactSelect.tsx +++ b/app/react/components/form-components/ReactSelect.tsx @@ -5,14 +5,12 @@ import ReactSelectAsync, { AsyncProps as ReactSelectAsyncProps, } from 'react-select/async'; import ReactSelect, { - components, GroupBase, - InputProps, OptionsOrGroups, Props as ReactSelectProps, } from 'react-select'; import clsx from 'clsx'; -import { RefAttributes, useMemo, useCallback } from 'react'; +import { RefAttributes, useMemo } from 'react'; import ReactSelectType from 'react-select/dist/declarations/src/Select'; import './ReactSelect.css'; @@ -54,9 +52,6 @@ type Props< | CreatableProps | RegularProps; -/** - * DO NOT use this component directly, use PortainerSelect instead. - */ export function Select< Option = DefaultOption, IsMulti extends boolean = false, @@ -73,37 +68,24 @@ export function Select< id: string; }) { const Component = isCreatable ? ReactSelectCreatable : ReactSelect; - const { - options, - 'data-cy': dataCy, - components: componentsProp, - ...rest - } = props; - - const memoizedComponents = useMemoizedSelectComponents< - Option, - IsMulti, - Group - >(dataCy, componentsProp); + const { options } = props; if ((options?.length || 0) > 1000) { return ( ); } return ( ); } @@ -112,25 +94,13 @@ export function Creatable< Option = DefaultOption, IsMulti extends boolean = false, Group extends GroupBase
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx index d8a84a214..0aa05960c 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx @@ -11,7 +11,7 @@ import { Badge } from '@@/Badge'; import { Icon } from '@@/Icon'; import { HelmRelease } from '../../types'; -import { useHelmHistory } from '../../helmReleaseQueries/useHelmHistory'; +import { useHelmHistory } from '../queries/useHelmHistory'; import { ManifestDetails } from './ManifestDetails'; import { NotesDetails } from './NotesDetails'; diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx index 100b68fc0..1bae99912 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx @@ -3,7 +3,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { GenericResource } from '@/react/kubernetes/helm/types'; + +import { GenericResource } from '../../../types'; import { ResourcesTable } from './ResourcesTable'; @@ -21,7 +22,7 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({ useEnvironmentId: () => mockUseEnvironmentId(), })); -vi.mock('@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease', () => ({ +vi.mock('../../queries/useHelmRelease', () => ({ useHelmRelease: () => mockUseHelmRelease(), })); diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx index e661e964c..1757535ba 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx @@ -1,7 +1,6 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useHelmRelease } from '@/react/kubernetes/helm/helmReleaseQueries/useHelmRelease'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { @@ -14,6 +13,8 @@ import { Widget } from '@@/Widget'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { TextTip } from '@@/Tip/TextTip'; +import { useHelmRelease } from '../../queries/useHelmRelease'; + import { columns } from './columns'; import { useResourceRows } from './useResourceRows'; diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts index c16b0e885..ac90271f8 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { GenericResource } from '@/react/kubernetes/helm/types'; - import { StatusBadgeType } from '@@/StatusBadge'; +import { GenericResource } from '../../../types'; + import { ResourceLink, ResourceRow } from './types'; // from defined routes in app/kubernetes/__module.js diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts index e15e189fa..814f03a6a 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts @@ -1,7 +1,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { HelmRelease } from '../../types'; -import { useHelmRelease } from '../../helmReleaseQueries/useHelmRelease'; +import { useHelmRelease } from '../queries/useHelmRelease'; import { DiffViewMode } from './DiffControl'; diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useHelmHistory.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts similarity index 88% rename from app/react/kubernetes/helm/helmReleaseQueries/useHelmHistory.ts rename to app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts index 1f69a8a5b..41d6ab375 100644 --- a/app/react/kubernetes/helm/helmReleaseQueries/useHelmHistory.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts @@ -4,9 +4,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { HelmRelease } from '../types'; - -import { queryKeys } from './query-keys'; +import { HelmRelease } from '../../types'; export function useHelmHistory( environmentId: EnvironmentId, @@ -14,7 +12,7 @@ export function useHelmHistory( namespace: string ) { return useQuery( - queryKeys.releaseHistory(environmentId, namespace, name), + [environmentId, 'helm', 'releases', namespace, name, 'history'], () => getHelmHistory(environmentId, name, namespace), { enabled: !!environmentId && !!name && !!namespace, diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts similarity index 91% rename from app/react/kubernetes/helm/helmReleaseQueries/useHelmRelease.ts rename to app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts index 4f377fb7a..cdf465c16 100644 --- a/app/react/kubernetes/helm/helmReleaseQueries/useHelmRelease.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts @@ -4,9 +4,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { HelmRelease } from '../types'; - -import { queryKeys } from './query-keys'; +import { HelmRelease } from '../../types'; type Options = { select?: (data: HelmRelease) => T; @@ -29,7 +27,15 @@ export function useHelmRelease( const { select, showResources, refetchInterval, revision, staleTime } = options; return useQuery( - queryKeys.release(environmentId, namespace, name, revision, showResources), + [ + environmentId, + 'helm', + 'releases', + namespace, + name, + revision, + showResources, + ], () => getHelmRelease(environmentId, name, { namespace, diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useHelmRollbackMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts similarity index 88% rename from app/react/kubernetes/helm/helmReleaseQueries/useHelmRollbackMutation.ts rename to app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts index 81b1bff39..6aea3ad29 100644 --- a/app/react/kubernetes/helm/helmReleaseQueries/useHelmRollbackMutation.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts @@ -7,9 +7,7 @@ import { withGlobalError, } from '@/react-tools/react-query'; import axios from '@/portainer/services/axios'; -import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys'; - -import { queryKeys } from './query-keys'; +import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys'; /** * Parameters for helm rollback operation @@ -56,8 +54,8 @@ export function useHelmRollbackMutation(environmentId: EnvironmentId) { rollbackRelease({ releaseName, params, environmentId }), ...withGlobalError('Unable to rollback Helm release'), ...withInvalidate(queryClient, [ - queryKeys.releases(environmentId), - applicationsQueryKeys.applications(environmentId), + [environmentId, 'helm', 'releases'], + queryKeys.applications(environmentId), ]), }); } diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useUninstallHelmAppMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation.ts similarity index 94% rename from app/react/kubernetes/helm/helmReleaseQueries/useUninstallHelmAppMutation.ts rename to app/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation.ts index 16341eb76..7daa5835c 100644 --- a/app/react/kubernetes/helm/helmReleaseQueries/useUninstallHelmAppMutation.ts +++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation.ts @@ -5,8 +5,6 @@ import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { queryKeys } from './query-keys'; - export function useUninstallHelmAppMutation(environmentId: EnvironmentId) { const queryClient = useQueryClient(); return useMutation({ @@ -18,7 +16,6 @@ export function useUninstallHelmAppMutation(environmentId: EnvironmentId) { namespace?: string; }) => uninstallHelmApplication(environmentId, releaseName, namespace), ...withInvalidate(queryClient, [ - queryKeys.releases(environmentId), applicationsQueryKeys.applications(environmentId), ]), ...withGlobalError('Unable to uninstall helm application'), diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx index ca3aa4c3a..80d39eb3e 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx @@ -36,15 +36,14 @@ vi.mock('@/portainer/services/notifications', () => ({ ), })); -vi.mock('../helmReleaseQueries/useUpdateHelmReleaseMutation', () => ({ +vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({ useUpdateHelmReleaseMutation: vi.fn(() => ({ mutateAsync: vi.fn((...args) => mockMutate(...args)), isLoading: false, })), - updateHelmRelease: vi.fn(() => Promise.resolve({})), })); -vi.mock('../helmChartSourceQueries/useHelmRepoVersions', () => ({ +vi.mock('../queries/useHelmRepoVersions', () => ({ useHelmRepoVersions: vi.fn(() => ({ data: [ { Version: '1.0.0', AppVersion: '1.0.0' }, @@ -99,7 +98,6 @@ function renderComponent({ selectedChart={selectedChart} namespace={namespace} name={name} - isRepoAvailable /> )), user diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx index 2bed5b58f..669dbd46c 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx @@ -11,11 +11,7 @@ import { confirmGenericDiscard } from '@@/modals/confirm'; import { Option } from '@@/form-components/PortainerSelect'; import { Chart } from '../types'; -import { useUpdateHelmReleaseMutation } from '../helmReleaseQueries/useUpdateHelmReleaseMutation'; -import { - ChartVersion, - useHelmRepoVersions, -} from '../helmChartSourceQueries/useHelmRepoVersions'; +import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation'; import { HelmInstallInnerForm } from './HelmInstallInnerForm'; import { HelmInstallFormValues } from './types'; @@ -24,39 +20,22 @@ type Props = { selectedChart: Chart; namespace?: string; name?: string; - isRepoAvailable: boolean; }; -export function HelmInstallForm({ - selectedChart, - namespace, - name, - isRepoAvailable, -}: Props) { +export function HelmInstallForm({ selectedChart, namespace, name }: Props) { const environmentId = useEnvironmentId(); const router = useRouter(); const analytics = useAnalytics(); - const helmRepoVersionsQuery = useHelmRepoVersions( - selectedChart.name, - 60 * 60 * 1000, // 1 hour - [ - { - repo: selectedChart.repo, - }, - ] - ); - const versions = helmRepoVersionsQuery.data; - const versionOptions: Option[] = versions.map( + const versionOptions: Option[] = selectedChart.versions.map( (version, index) => ({ - label: index === 0 ? `${version.Version} (latest)` : version.Version, + label: index === 0 ? `${version} (latest)` : version, value: version, }) ); const defaultVersion = versionOptions[0]?.value; const initialValues: HelmInstallFormValues = { values: '', - version: defaultVersion?.Version ?? '', - repo: defaultVersion?.Repo ?? selectedChart.repo ?? '', + version: defaultVersion ?? '', }; const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId); @@ -76,8 +55,6 @@ export function HelmInstallForm({ namespace={namespace} name={name} versionOptions={versionOptions} - isVersionsLoading={helmRepoVersionsQuery.isInitialLoading} - isRepoAvailable={isRepoAvailable} /> ); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx index 77ba91ca1..9f85a0b48 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx @@ -1,18 +1,14 @@ import { Form, useFormikContext } from 'formik'; -import { useMemo, useState } from 'react'; - -import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useMemo } from 'react'; +import { FormActions } from '@@/form-components/FormActions'; import { FormControl } from '@@/form-components/FormControl'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { FormSection } from '@@/form-components/FormSection'; -import { LoadingButton } from '@@/buttons'; import { Chart } from '../types'; -import { useHelmChartValues } from '../helmChartSourceQueries/useHelmChartValues'; +import { useHelmChartValues } from '../queries/useHelmChartValues'; import { HelmValuesInput } from '../components/HelmValuesInput'; -import { ChartVersion } from '../helmChartSourceQueries/useHelmRepoVersions'; -import { ManifestPreviewFormSection } from '../components/ManifestPreviewFormSection'; import { HelmInstallFormValues } from './types'; @@ -20,9 +16,7 @@ type Props = { selectedChart: Chart; namespace?: string; name?: string; - versionOptions: Option[]; - isVersionsLoading: boolean; - isRepoAvailable: boolean; + versionOptions: Option[]; }; export function HelmInstallInnerForm({ @@ -30,60 +24,21 @@ export function HelmInstallInnerForm({ namespace, name, versionOptions, - isVersionsLoading, - isRepoAvailable, }: Props) { - const environmentId = useEnvironmentId(); - const [previewIsValid, setPreviewIsValid] = useState(false); const { values, setFieldValue, isSubmitting } = useFormikContext(); - const selectedVersion: ChartVersion | undefined = useMemo( - () => - versionOptions.find( - (v) => - v.value.Version === values.version && - v.value.Repo === selectedChart.repo - )?.value ?? versionOptions[0]?.value, - [versionOptions, values.version, selectedChart.repo] - ); - - const repoParams = { + const chartValuesRefQuery = useHelmChartValues({ + chart: selectedChart.name, repo: selectedChart.repo, - }; - // use isLatestVersionFetched to cache the latest version, to avoid duplicate fetches - const isLatestVersionFetched = - // if no version is selected, the latest version gets fetched - !versionOptions.length || - // otherwise check if the selected version is the latest version - (selectedVersion?.Version === versionOptions[0]?.value.Version && - selectedVersion?.Repo === versionOptions[0]?.value.Repo); - const chartValuesRefQuery = useHelmChartValues( - { - chart: selectedChart.name, - version: values?.version, - ...repoParams, - }, - isLatestVersionFetched - ); + version: values?.version, + }); - const payload = useMemo( - () => ({ - name: name || '', - namespace: namespace || '', - chart: selectedChart.name, - version: values?.version, - repo: selectedChart.repo, - values: values.values, - }), - [ - name, - namespace, - selectedChart.name, - values?.version, - selectedChart.repo, - values.values, - ] + const selectedVersion = useMemo( + () => + versionOptions.find((v) => v.value === values.version)?.value ?? + versionOptions[0]?.value, + [versionOptions, values.version] ); return ( @@ -93,18 +48,14 @@ export function HelmInstallInnerForm({ - + value={selectedVersion} options={versionOptions} - noOptionsMessage={() => 'No versions found'} - placeholder="Select a version" onChange={(version) => { if (version) { - setFieldValue('version', version.Version); - setFieldValue('repo', version.Repo); + setFieldValue('version', version); } }} data-cy="helm-version-input" @@ -117,23 +68,15 @@ export function HelmInstallInnerForm({ isValuesRefLoading={chartValuesRefQuery.isInitialLoading} /> -
- - Install - + /> ); } diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx index 5675ff5d7..ffd122cd2 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx @@ -1,20 +1,15 @@ import { useState } from 'react'; +import { compact } from 'lodash'; import { useCurrentUser } from '@/react/hooks/useUser'; -import { FormSection } from '@@/form-components/FormSection'; - -import { useHelmHTTPChartList } from '../helmChartSourceQueries/useHelmChartList'; import { Chart } from '../types'; -import { - HelmRegistrySelect, - RepoValue, -} from '../components/HelmRegistrySelect'; -import { useHelmRepoOptions } from '../helmChartSourceQueries/useHelmRepositories'; +import { useHelmChartList } from '../queries/useHelmChartList'; +import { useHelmRegistries } from '../queries/useHelmRegistries'; -import { HelmInstallForm } from './HelmInstallForm'; -import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; import { HelmTemplatesList } from './HelmTemplatesList'; +import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem'; +import { HelmInstallForm } from './HelmInstallForm'; interface Props { onSelectHelmChart: (chartName: string) => void; @@ -24,60 +19,11 @@ interface Props { export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { const [selectedChart, setSelectedChart] = useState(null); - const [selectedRepo, setSelectedRepo] = useState(null); + const [selectedRegistry, setSelectedRegistry] = useState(null); + const { user } = useCurrentUser(); - const chartListQuery = useHelmHTTPChartList( - user.Id, - selectedRepo?.repoUrl ?? '', - !!selectedRepo?.repoUrl - ); - const repoOptionsQuery = useHelmRepoOptions(); - const isRepoAvailable = - !!repoOptionsQuery.data && repoOptionsQuery.data.length > 0; - - return ( -
-
- - {selectedChart ? ( - <> - - - - ) : ( - <> - - {selectedRepo && ( - - )} - - )} - -
-
- ); - + const helmReposQuery = useHelmRegistries(); + const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry])); function clearHelmChart() { setSelectedChart(null); onSelectHelmChart(''); @@ -87,4 +33,33 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { setSelectedChart(chart); onSelectHelmChart(chart.name); } + + return ( +
+
+ {selectedChart ? ( + <> + + + + ) : ( + + )} +
+
+ ); } diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx index bfaa6e7e8..98b96e9a4 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx @@ -46,63 +46,25 @@ const mockCharts: Chart[] = [ const selectActionMock = vi.fn(); -const mockUseEnvironmentId = vi.fn(() => 1); - -vi.mock('@/react/hooks/useEnvironmentId', () => ({ - useEnvironmentId: () => mockUseEnvironmentId(), -})); - -// Mock the helm registries query -vi.mock('../queries/useHelmRegistries', () => ({ - useHelmRegistries: vi.fn(() => ({ - data: ['https://example.com', 'https://example.com/2'], - isInitialLoading: false, - isError: false, - })), -})); - -// Mock the environment registries query -vi.mock( - '@/react/portainer/environments/queries/useEnvironmentRegistries', - () => ({ - useEnvironmentRegistries: vi.fn(() => ({ - data: [ - { Id: 1, URL: 'https://registry.example.com' }, - { Id: 2, URL: 'https://registry2.example.com' }, - ], - isInitialLoading: false, - isError: false, - })), - }) -); - function renderComponent({ loading = false, charts = mockCharts, selectAction = selectActionMock, - selectedRegistry = { - repoUrl: 'https://example.com', - name: 'Test Registry', - }, -}: { - loading?: boolean; - charts?: Chart[]; - selectAction?: (chart: Chart) => void; - selectedRegistry?: { - repoUrl?: string; - name?: string; - } | null; + selectedRegistry = '', } = {}) { const user = new UserViewModel({ Username: 'user' }); + const registries = ['https://example.com', 'https://example.com/2']; const Wrapped = withTestQueryProvider( withUserProvider( withTestRouter(() => ( {}} /> )), user @@ -119,10 +81,8 @@ describe('HelmTemplatesList', () => { it('should display title and charts list', async () => { renderComponent(); - // Check for the title with registry name - expect( - screen.getByText('Select a helm chart from Test Registry') - ).toBeInTheDocument(); + // Check for the title + expect(screen.getByText('Helm chart')).toBeInTheDocument(); // Check for charts expect(screen.getByText('test-chart-1')).toBeInTheDocument(); @@ -200,27 +160,21 @@ describe('HelmTemplatesList', () => { }); it('should show empty message when no charts are available and a registry is selected', async () => { - renderComponent({ - charts: [], - selectedRegistry: { - repoUrl: 'https://example.com', - name: 'Test Registry', - }, - }); + renderComponent({ charts: [], selectedRegistry: 'https://example.com' }); // Check for empty message expect( - screen.getByText('No helm charts available in this repository.') + screen.getByText('No helm charts available in this registry.') ).toBeInTheDocument(); }); it("should show 'select registry' message when no charts are available and no registry is selected", async () => { - renderComponent({ charts: [], selectedRegistry: null }); + renderComponent({ charts: [] }); // Check for message expect( screen.getByText( - 'Please select a repository to view available Helm charts.' + 'Please select a registry to view available Helm charts.' ) ).toBeInTheDocument(); }); diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx index 3d9e86578..1b02bed1d 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx @@ -1,47 +1,59 @@ import { useState, useMemo } from 'react'; +import { components, OptionProps } from 'react-select'; -import { PortainerSelect } from '@/react/components/form-components/PortainerSelect'; +import { + PortainerSelect, + Option, +} from '@/react/components/form-components/PortainerSelect'; +import { Link } from '@/react/components/Link'; +import { InsightsBox } from '@@/InsightsBox'; import { SearchBar } from '@@/datatables/SearchBar'; import { InlineLoader } from '@@/InlineLoader'; import { Chart } from '../types'; -import { RepoValue } from '../components/HelmRegistrySelect'; import { HelmTemplatesListItem } from './HelmTemplatesListItem'; interface Props { - isLoadingCharts: boolean; + isLoading: boolean; charts?: Chart[]; selectAction: (chart: Chart) => void; - selectedRegistry: RepoValue | null; + registries: string[]; + selectedRegistry: string | null; + setSelectedRegistry: (registry: string | null) => void; } export function HelmTemplatesList({ - isLoadingCharts, + isLoading, charts = [], selectAction, + registries, selectedRegistry, + setSelectedRegistry, }: Props) { const [textFilter, setTextFilter] = useState(''); const [selectedCategory, setSelectedCategory] = useState(null); const categories = useMemo(() => getCategories(charts), [charts]); + const registryOptions = useMemo( + () => + registries.map((registry) => ({ + label: registry, + value: registry, + })), + [registries] + ); const filteredCharts = useMemo( () => getFilteredCharts(charts, textFilter, selectedCategory), [charts, textFilter, selectedCategory] ); - const isSelectedRegistryEmpty = - !isLoadingCharts && charts.length === 0 && selectedRegistry; - return (
-
-
- Select a helm chart from {selectedRegistry?.name} -
+
+
Helm chart
-
+
+ +
+ +
+
+
+ Select the Helm chart to use. Bring further Helm charts into your + selection list via{' '} + + User settings - Helm repositories + + . +
+ + + At present Portainer does not support OCI format Helm charts. + Support for OCI charts will be available in a future release. +
+ If you would like to provide feedback on OCI support or get access + to early releases to test this functionality,{' '} + + please get in touch + + . + + } + /> +
{filteredCharts.map((chart) => ( @@ -77,7 +138,7 @@ export function HelmTemplatesList({
No Helm charts found
)} - {isLoadingCharts && ( + {isLoading && (
Loading helm charts... @@ -90,15 +151,15 @@ export function HelmTemplatesList({
)} - {isSelectedRegistryEmpty && ( + {!isLoading && charts.length === 0 && selectedRegistry && (
- No helm charts available in this repository. + No helm charts available in this registry.
)} {!selectedRegistry && (
- Please select a repository to view available Helm charts. + Please select a registry to view available Helm charts.
)}
@@ -106,6 +167,20 @@ export function HelmTemplatesList({ ); } +// truncate the registry text, because some registry names are urls, which are too long +function RegistryOption(props: OptionProps>) { + const { data: registry } = props; + + return ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + {registry.value} + +
+ ); +} + /** * Get categories from charts * @param charts - The charts to get the categories from diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx index cc85170e2..d6685f3c6 100644 --- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx +++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx @@ -26,7 +26,7 @@ export function HelmTemplatesSelectedItem({
diff --git a/app/react/kubernetes/helm/HelmTemplates/types.ts b/app/react/kubernetes/helm/HelmTemplates/types.ts index 61a8451c9..df0b09374 100644 --- a/app/react/kubernetes/helm/HelmTemplates/types.ts +++ b/app/react/kubernetes/helm/HelmTemplates/types.ts @@ -1,5 +1,4 @@ export type HelmInstallFormValues = { values: string; version: string; - repo: string; }; diff --git a/app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx b/app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx deleted file mode 100644 index 5934e75ba..000000000 --- a/app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; - -import selectEvent from '@/react/test-utils/react-select'; -import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { withUserProvider } from '@/react/test-utils/withUserProvider'; -import { withTestRouter } from '@/react/test-utils/withRouter'; -import { UserViewModel } from '@/portainer/models/user'; -import { RegistryTypes } from '@/react/portainer/registries/types/registry'; -import { useCurrentUser } from '@/react/hooks/useUser'; -import { User, Role } from '@/portainer/users/types'; - -import { HelmRegistrySelect, RepoValue } from './HelmRegistrySelect'; - -// Mock the hooks with factory functions - preserve other exports -vi.mock('@/react/hooks/useUser', async () => { - const actual = await vi.importActual('@/react/hooks/useUser'); - return { - ...actual, - useCurrentUser: vi.fn(), - }; -}); - -const mockOnRegistryChange = vi.fn(); - -const defaultProps = { - selectedRegistry: null, - onRegistryChange: mockOnRegistryChange, - isRepoAvailable: true, - isLoading: false, - isError: false, - repoOptions: [], -}; - -const mockRepoOptions = [ - { - value: { - repoUrl: 'https://charts.bitnami.com/bitnami', - name: 'Bitnami', - type: RegistryTypes.CUSTOM, - }, - label: 'Bitnami', - }, - { - value: { - repoUrl: 'https://kubernetes-charts.storage.googleapis.com', - name: 'Stable', - type: RegistryTypes.CUSTOM, - }, - label: 'Stable', - }, -]; - -interface MockUserHookReturn { - user: User; - isPureAdmin: boolean; -} - -interface UserProps { - isPureAdmin?: boolean; -} - -// Get the mocked functions -const mockUseCurrentUser = vi.mocked(useCurrentUser); - -function renderComponent(props = {}, userProps: UserProps = {}) { - const userResult: MockUserHookReturn = { - user: { - Id: 1, - Username: 'admin', - Role: Role.Admin, - EndpointAuthorizations: {}, - UseCache: false, - ThemeSettings: { - color: 'auto', - }, - }, - isPureAdmin: userProps.isPureAdmin || false, - }; - - mockUseCurrentUser.mockReturnValue(userResult); - - const Component = withTestQueryProvider( - withUserProvider( - withTestRouter(HelmRegistrySelect), - new UserViewModel({ Username: 'admin', Role: 1 }) - ) - ); - - return render(); -} - -describe('HelmRegistrySelect', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseCurrentUser.mockClear(); - }); - - describe('Basic rendering', () => { - it('should render with default placeholder', () => { - renderComponent(); - expect(screen.getByText('Select a repository')).toBeInTheDocument(); - }); - - it('should render with custom placeholder', () => { - renderComponent({ placeholder: 'Custom placeholder' }); - expect(screen.getByText('Custom placeholder')).toBeInTheDocument(); - }); - - it('should render loading state', () => { - renderComponent({ isLoading: true }); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('should render error state', () => { - renderComponent({ isError: true }); - expect( - screen.getByText('Unable to load registry options.') - ).toBeInTheDocument(); - }); - }); - - describe('Repository options', () => { - it('should display repository options', async () => { - const user = userEvent.setup(); - renderComponent({ repoOptions: mockRepoOptions }); - - const select = screen.getByRole('combobox'); - await user.click(select); - - expect(screen.getByText('Bitnami')).toBeInTheDocument(); - expect(screen.getByText('Stable')).toBeInTheDocument(); - }); - - it.skip('should call onRegistryChange when option is selected', async () => { - // Skipping this test due to react-select testing complexity - // The onChange functionality is covered by integration tests - renderComponent({ repoOptions: mockRepoOptions }); - - const select = screen.getByRole('combobox'); - await selectEvent.select(select, 'Bitnami'); - - expect(mockOnRegistryChange).toHaveBeenCalledWith({ - repoUrl: 'https://charts.bitnami.com/bitnami', - name: 'Bitnami', - type: RegistryTypes.CUSTOM, - }); - }); - - it('should show selected repository value', () => { - const selectedRegistry: RepoValue = { - repoUrl: 'https://charts.bitnami.com/bitnami', - name: 'Bitnami', - type: RegistryTypes.CUSTOM, - }; - - renderComponent({ - selectedRegistry, - repoOptions: mockRepoOptions, - }); - - // Since the component uses PortainerSelect which manages the display value, - // we verify the props are correctly passed by checking the select element exists - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - }); - - describe('No repositories warning', () => { - it('should show no repositories warning when no repos are available', () => { - renderComponent({ - isRepoAvailable: false, - namespace: 'test-namespace', - }); - - expect( - screen.getByText(/There are no repositories available./) - ).toBeInTheDocument(); - }); - - it('should not show warning when loading', () => { - renderComponent({ - isRepoAvailable: false, - namespace: 'test-namespace', - isLoading: true, - }); - - expect( - screen.queryByText('There are no repositories available.') - ).not.toBeInTheDocument(); - }); - - it('should not show warning when no namespace is provided', () => { - renderComponent({ - isRepoAvailable: false, - }); - - expect( - screen.queryByText('There are no repositories available.') - ).not.toBeInTheDocument(); - }); - }); - - describe('Tooltip content', () => { - it('should render the component with label and tooltip', () => { - renderComponent({}, { isPureAdmin: true }); - - // Verify that the component renders the main label - expect(screen.getByText('Helm chart source')).toBeInTheDocument(); - - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - }); - - describe('Loading and error states', () => { - it('should not show no repos warning when loading', () => { - renderComponent({ - isLoading: true, - isRepoAvailable: false, - repoOptions: [], - namespace: 'test-namespace', - }); - - expect( - screen.queryByText('There are no repositories available.') - ).not.toBeInTheDocument(); - }); - - it('should show error when API fails', () => { - renderComponent({ - isLoading: false, - isError: true, - isRepoAvailable: false, - namespace: 'test-namespace', - }); - - expect( - screen.getByText('Unable to load registry options.') - ).toBeInTheDocument(); - }); - }); -}); diff --git a/app/react/kubernetes/helm/components/HelmRegistrySelect.tsx b/app/react/kubernetes/helm/components/HelmRegistrySelect.tsx deleted file mode 100644 index 55569f900..000000000 --- a/app/react/kubernetes/helm/components/HelmRegistrySelect.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { GroupBase } from 'react-select'; - -import { - PortainerSelect, - Option, -} from '@/react/components/form-components/PortainerSelect'; -import { useCurrentUser } from '@/react/hooks/useUser'; -import { RegistryTypes } from '@/react/portainer/registries/types/registry'; - -import { FormControl } from '@@/form-components/FormControl'; -import { Alert } from '@@/Alert'; -import { Link } from '@@/Link'; -import { TextTip } from '@@/Tip/TextTip'; - -export type RepoValue = { - repoUrl?: string; // set for traditional https helm repos - name?: string; - type?: RegistryTypes; -}; - -interface Props { - selectedRegistry: RepoValue | null; - onRegistryChange: (registry: RepoValue | null) => void; - namespace?: string; - placeholder?: string; - 'data-cy'?: string; - isRepoAvailable: boolean; - isLoading: boolean; - isError: boolean; - repoOptions: GroupBase>[]; -} - -export function HelmRegistrySelect({ - selectedRegistry, - onRegistryChange, - namespace, - placeholder = 'Select a repository', - 'data-cy': dataCy = 'helm-registry-select', - isRepoAvailable, - isLoading, - isError, - repoOptions, -}: Props) { - const { isPureAdmin } = useCurrentUser(); - - return ( - } - > - - placeholder={placeholder} - value={selectedRegistry ?? {}} - options={repoOptions} - isLoading={isLoading} - onChange={onRegistryChange} - isClearable - bindToBody - data-cy={dataCy} - /> - - {isError && Unable to load registry options.} - - ); -} - -function HelmChartSourceTooltip({ isPureAdmin }: { isPureAdmin: boolean }) { - if (isPureAdmin) { - return ( - <> - -
- - - ); - } - - // Non-admin - return ; -} - -function NoReposWarning({ - hasNoRepos, - isLoading, - namespace, - isPureAdmin, -}: { - hasNoRepos: boolean; - isLoading: boolean; - namespace?: string; - isPureAdmin: boolean; -}) { - if (!hasNoRepos || isLoading || !namespace) { - return null; - } - - return ( - - There are no repositories available. - - - ); -} - -function CreateRepoMessage({ isPureAdmin }: { isPureAdmin: boolean }) { - if (isPureAdmin) { - return ( - <> - -
- - - ); - } - - // Non-admin - return ; -} - -function CreateUserRepoMessage() { - return ( - <> - You can define repositories in the{' '} - - User settings - Helm repositories - - . - - ); -} - -function CreateGlobalRepoMessage() { - return ( - <> - You can also define repositories in the{' '} - - Portainer settings - - . - - ); -} diff --git a/app/react/kubernetes/helm/components/ManifestPreviewFormSection.test.tsx b/app/react/kubernetes/helm/components/ManifestPreviewFormSection.test.tsx deleted file mode 100644 index 6ca6f2841..000000000 --- a/app/react/kubernetes/helm/components/ManifestPreviewFormSection.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; - -import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { withUserProvider } from '@/react/test-utils/withUserProvider'; -import { withTestRouter } from '@/react/test-utils/withRouter'; -import { UserViewModel } from '@/portainer/models/user'; - -import { ManifestPreviewFormSection } from './ManifestPreviewFormSection'; - -// Mock the necessary hooks -const mockUseHelmDryRun = vi.fn(); -const mockUseDebouncedValue = vi.fn(); - -vi.mock('../helmReleaseQueries/useHelmDryRun', () => ({ - useHelmDryRun: (...args: unknown[]) => mockUseHelmDryRun(...args), -})); - -vi.mock('@/react/hooks/useDebouncedValue', () => ({ - useDebouncedValue: (value: unknown, delay: number) => - mockUseDebouncedValue(value, delay), -})); - -// Mock the CodeEditor and DiffViewer components -vi.mock('@@/CodeEditor', () => ({ - CodeEditor: ({ - 'data-cy': dataCy, - value, - }: { - 'data-cy'?: string; - value: string; - }) => ( -
- {value} -
- ), -})); - -vi.mock('@@/CodeEditor/DiffViewer', () => ({ - DiffViewer: ({ - 'data-cy': dataCy, - originalCode, - newCode, - }: { - 'data-cy'?: string; - originalCode: string; - newCode: string; - }) => ( -
-
{originalCode}
-
{newCode}
-
- ), -})); - -const mockOnChangePreviewValidation = vi.fn(); - -const defaultProps = { - payload: { - name: 'test-release', - namespace: 'test-namespace', - chart: 'test-chart', - version: '1.0.0', - repo: 'test-repo', - }, - onChangePreviewValidation: mockOnChangePreviewValidation, - title: 'Manifest Preview', - environmentId: 1, -}; - -function renderComponent(props = {}) { - const user = new UserViewModel({ Username: 'user', Role: 1 }); - - const Component = withTestQueryProvider( - withUserProvider( - withTestRouter(() => ( - - )), - user - ) - ); - - return render(); -} - -describe('ManifestPreviewFormSection', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default mock for useDebouncedValue - returns the value as-is - mockUseDebouncedValue.mockImplementation((value) => value); - }); - - it('should show loading and no form section when loading', () => { - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: true, - isError: false, - data: undefined, - }); - - renderComponent(); - - expect( - screen.getByText('Generating manifest preview...') - ).toBeInTheDocument(); - expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument(); - }); - - it('should show error and no form section when error', () => { - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: false, - isError: true, - error: { message: 'Invalid chart configuration' }, - data: undefined, - }); - - renderComponent(); - - expect( - screen.getByText('Error with Helm chart configuration') - ).toBeInTheDocument(); - expect(screen.getByText('Invalid chart configuration')).toBeInTheDocument(); - expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument(); - }); - - it('should show single code editor when only the generated manifest is available', async () => { - const mockManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: test'; - - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: false, - isError: false, - data: { manifest: mockManifest }, - }); - - renderComponent(); - - expect(screen.getByText('Manifest Preview')).toBeInTheDocument(); - - // Expand the FormSection to see the content - const expandButton = screen.getByLabelText('Expand'); - await userEvent.click(expandButton); - - // Check that the manifest content is rendered (from the HTML, we can see it's there) - expect( - screen.getByText(/apiVersion/, { exact: false }) - ).toBeInTheDocument(); - expect(screen.getByText(/test/, { exact: false })).toBeInTheDocument(); - }); - - it('should show the diff when the current and generated manifest are available', async () => { - const currentManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: old'; - const newManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: new'; - - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: false, - isError: false, - data: { manifest: newManifest }, - }); - - renderComponent({ currentManifest }); - - expect(screen.getByText('Manifest Preview')).toBeInTheDocument(); - - // Expand the FormSection to see the content - const expandButton = screen.getByLabelText('Expand'); - await userEvent.click(expandButton); - - // Check that both old and new manifest content is rendered - expect(screen.getByText(/old/, { exact: false })).toBeInTheDocument(); - expect(screen.getByText(/new/, { exact: false })).toBeInTheDocument(); - }); - - it('should call onChangePreviewValidation with correct validation state', () => { - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: false, - isError: false, - data: { manifest: 'test' }, - }); - - renderComponent(); - - expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(true); - }); - - it('should call onChangePreviewValidation with false when error occurs', () => { - mockUseHelmDryRun.mockReturnValue({ - isInitialLoading: false, - isError: true, - error: { message: 'Error' }, - data: undefined, - }); - - renderComponent(); - - expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(false); - }); -}); diff --git a/app/react/kubernetes/helm/components/ManifestPreviewFormSection.tsx b/app/react/kubernetes/helm/components/ManifestPreviewFormSection.tsx deleted file mode 100644 index fa651fe1a..000000000 --- a/app/react/kubernetes/helm/components/ManifestPreviewFormSection.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { FormSection } from '@@/form-components/FormSection'; -import { CodeEditor } from '@@/CodeEditor'; -import { DiffViewer } from '@@/CodeEditor/DiffViewer'; -import { InlineLoader } from '@@/InlineLoader'; -import { Alert } from '@@/Alert'; -import { TextTip } from '@@/Tip/TextTip'; - -import { useHelmDryRun } from '../helmReleaseQueries/useHelmDryRun'; -import { UpdateHelmReleasePayload } from '../types'; - -type Props = { - payload: UpdateHelmReleasePayload; - onChangePreviewValidation: (isValid: boolean) => void; - currentManifest?: string; // only true on upgrade, not install - title: string; - environmentId: EnvironmentId; -}; - -export function ManifestPreviewFormSection({ - payload, - currentManifest, - onChangePreviewValidation, - title, - environmentId, -}: Props) { - const debouncedPayload = useDebouncedValue(payload, 500); - const manifestPreviewQuery = useHelmDryRun(environmentId, debouncedPayload); - const [isFolded, setIsFolded] = useState(true); - - useEffect(() => { - onChangePreviewValidation(!manifestPreviewQuery.isError); - }, [manifestPreviewQuery.isError, onChangePreviewValidation]); - - if ( - !debouncedPayload.name || - !debouncedPayload.namespace || - !debouncedPayload.chart - ) { - return null; - } - - // only show loading state or the error to keep the view simple (omitting the preview section because there is nothing to preview) - if (manifestPreviewQuery.isInitialLoading) { - return Generating manifest preview...; - } - - if (manifestPreviewQuery.isError) { - return ( - - {manifestPreviewQuery.error?.message || - 'Error generating manifest preview'} - - ); - } - - return ( - - - - ); -} - -function ManifestPreview({ - currentManifest, - newManifest, -}: { - currentManifest?: string; - newManifest: string; -}) { - if (!newManifest) { - return No manifest preview available; - } - - if (currentManifest) { - return ( - - ); - } - - return ( - - ); -} diff --git a/app/react/kubernetes/helm/helmChartSourceQueries/query-keys.ts b/app/react/kubernetes/helm/helmChartSourceQueries/query-keys.ts deleted file mode 100644 index d9b75313c..000000000 --- a/app/react/kubernetes/helm/helmChartSourceQueries/query-keys.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { UserId } from '@/portainer/users/types'; - -export const queryKeys = { - // Environment-scoped Helm queries (following kubernetes pattern) - base: (environmentId: EnvironmentId) => - ['environments', environmentId, 'kubernetes', 'helm'] as const, - - // User's helm repositories/registries - registries: (userId: UserId) => ['helm', 'registries', userId] as const, - - // Chart repository searches (global, not environment-specific) - repositories: (chart: string, repo?: string, useCache?: boolean) => - ['helm', 'repositories', chart, repo, useCache] as const, - - // Chart listings from repositories (user-specific) - charts: (userId: UserId, repository: string) => - ['helm', 'charts', userId, repository] as const, - - // Chart values (global, cached by chart/version) - chartValues: (repo: string, chart: string, version: string | 'latest') => - ['helm', 'chart-values', repo, chart, version] as const, - - chartVersions: ( - sourceId: number | string, - chart: string, - useCache?: boolean - ) => ['helm', 'registries', sourceId, chart, 'versions', useCache] as const, -}; diff --git a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartList.ts b/app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartList.ts deleted file mode 100644 index 5d15d8945..000000000 --- a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartList.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { compact } from 'lodash'; -import { useQuery } from '@tanstack/react-query'; - -import axios from '@/portainer/services/axios'; -import { withGlobalError } from '@/react-tools/react-query'; - -import { Chart, HelmChartsResponse } from '../types'; - -import { queryKeys } from './query-keys'; - -/** - * React hook to fetch helm charts from the provided HTTP repository. - * Charts are loaded from the specified repository URL. - * - * @param userId User ID - * @param repository Repository URL to fetch charts from - * @param enabled Flag indicating if the query should be enabled - * @returns Query result containing helm charts - */ -export function useHelmHTTPChartList( - userId: number, - repository: string, - enabled: boolean -) { - return useQuery({ - queryKey: queryKeys.charts(userId, repository), - queryFn: () => getChartsFromRepo(repository), - enabled: !!userId && !!repository && enabled, - // one request takes a long time, so fail early to get feedback to the user faster - retry: false, - ...withGlobalError(`Unable to retrieve Helm charts from ${repository}`), - cacheTime: 1000 * 60 * 60 * 8, // 8 hours so that the chart list populates faster (keep stale time the same to always revalidate) - }); -} - -async function getChartsFromRepo(repo: string): Promise { - try { - // Construct the URL with required repo parameter - const response = await axios.get('templates/helm', { - params: { repo }, - }); - - return compact( - Object.values(response.data.entries).map((versions) => - versions[0] - ? { - ...versions[0], - repo, - // versions are within this response too, so we don't need a new query to fetch versions when this is used - versions: versions.map((v) => v.version), - } - : null - ) - ); - } catch (error) { - // Ignore errors from chart repositories as some may error but others may not - return []; - } -} diff --git a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepositories.ts b/app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepositories.ts deleted file mode 100644 index e382164cb..000000000 --- a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepositories.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { compact } from 'lodash'; - -import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { UserId } from '@/portainer/users/types'; -import { withGlobalError } from '@/react-tools/react-query'; -import { useCurrentUser } from '@/react/hooks/useUser'; -import { Option } from '@/react/components/form-components/PortainerSelect'; - -import { HelmRegistriesResponse } from '../types'; -import { RepoValue } from '../components/HelmRegistrySelect'; - -import { queryKeys } from './query-keys'; - -/** - * Hook to fetch all Helm registries for the current user - */ -export function useUserHelmRepositories({ - select, -}: { - select?: (registries: HelmRegistriesResponse) => T; -} = {}) { - const { user } = useCurrentUser(); - return useQuery( - queryKeys.registries(user.Id), - async () => getUserHelmRepositories(user.Id), - { - enabled: !!user.Id, - select, - ...withGlobalError('Unable to retrieve helm registries'), - } - ); -} - -export function useHelmRepoOptions() { - return useUserHelmRepositories({ - select: (registries) => { - const registryArray = flattenHelmRegistries(registries); - const repoOptions = registryArray - .map>((registry) => ({ - label: registry, - value: { - repoUrl: registry, - isOCI: false, - name: registry, - }, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - return [ - { - label: 'Helm Repositories', - options: repoOptions, - }, - { - label: 'OCI Registries', - options: [ - { - label: - 'Installing from an OCI registry is a Portainer Business Feature', - value: {}, - disabled: true, - }, - ], - }, - ]; - }, - }); -} - -/** - * Get Helm repositories for user - */ -async function getUserHelmRepositories(userId: UserId) { - try { - const { data } = await axios.get( - `users/${userId}/helm/repositories` - ); - return data; - } catch (err) { - throw parseAxiosError(err, 'Unable to retrieve helm repositories for user'); - } -} - -/** get the unique global and user registries in one array */ -export function flattenHelmRegistries(registries: HelmRegistriesResponse) { - // compact will remove the global repository if it's empty - const repos = compact([ - registries.GlobalRepository.toLowerCase(), - ...registries.UserRepositories.map((repo) => repo.URL.toLowerCase()), - ]); - return [...new Set(repos)]; -} diff --git a/app/react/kubernetes/helm/helmReleaseQueries/query-keys.ts b/app/react/kubernetes/helm/helmReleaseQueries/query-keys.ts deleted file mode 100644 index c12ad5c96..000000000 --- a/app/react/kubernetes/helm/helmReleaseQueries/query-keys.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; - -import { UpdateHelmReleasePayload } from '../types'; - -export const queryKeys = { - // Environment-scoped Helm queries (following kubernetes pattern) - base: (environmentId: EnvironmentId) => - [ - ...environmentQueryKeys.item(environmentId), - 'kubernetes', - 'helm', - ] as const, - - // Helm releases (environment-specific) - releases: (environmentId: EnvironmentId) => - [...queryKeys.base(environmentId), 'releases'] as const, - - release: ( - environmentId: EnvironmentId, - namespace: string, - name: string, - revision?: number, - showResources?: boolean - ) => - [ - ...queryKeys.releases(environmentId), - namespace, - name, - revision, - showResources, - ] as const, - - releaseHistory: ( - environmentId: EnvironmentId, - namespace: string, - name: string - ) => - [...queryKeys.release(environmentId, namespace, name), 'history'] as const, - - // Environment-specific install operations - installDryRun: ( - environmentId: EnvironmentId, - payload: UpdateHelmReleasePayload - ) => - [...queryKeys.base(environmentId), 'install', 'dry-run', payload] as const, -}; diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useHelmDryRun.ts b/app/react/kubernetes/helm/helmReleaseQueries/useHelmDryRun.ts deleted file mode 100644 index f36c4204d..000000000 --- a/app/react/kubernetes/helm/helmReleaseQueries/useHelmDryRun.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery, UseQueryResult } from '@tanstack/react-query'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import PortainerError from '@/portainer/error'; - -import { HelmRelease, UpdateHelmReleasePayload } from '../types'; - -import { updateHelmRelease } from './useUpdateHelmReleaseMutation'; -import { queryKeys } from './query-keys'; - -export function useHelmDryRun( - environmentId: EnvironmentId, - payload: UpdateHelmReleasePayload -): UseQueryResult { - return useQuery({ - queryKey: queryKeys.installDryRun(environmentId, payload), - queryFn: () => - // use updateHelmRelease as if it were a get request with dryRun. The payload is debounced to prevent too many requests. - updateHelmRelease( - environmentId, - payload, - { dryRun: true }, - { - errorMessage: 'Unable to get Helm manifest preview', - } - ), - // don't display error toast, handle it within the component - enabled: - !!payload.repo && - !!payload.chart && - !!payload.name && - !!payload.namespace && - !!payload.version, - retry: false, - refetchOnWindowFocus: false, - staleTime: 1000 * 60, // small 1 minute stale time to reduce the number of requests - }); -} diff --git a/app/react/kubernetes/helm/queries/useHelmChartList.ts b/app/react/kubernetes/helm/queries/useHelmChartList.ts new file mode 100644 index 000000000..5824236bc --- /dev/null +++ b/app/react/kubernetes/helm/queries/useHelmChartList.ts @@ -0,0 +1,75 @@ +import { useQueries } from '@tanstack/react-query'; +import { compact, flatMap } from 'lodash'; +import { useMemo } from 'react'; + +import axios from '@/portainer/services/axios'; +import { withGlobalError } from '@/react-tools/react-query'; + +import { Chart, HelmChartsResponse } from '../types'; + +/** + * React hook to fetch helm charts from the provided repositories + * Charts from each repository are loaded independently, allowing the UI + * to show charts as they become available instead of waiting for all + * repositories to load + * + * @param userId User ID + * @param repositories List of repository URLs to fetch charts from + */ +export function useHelmChartList(userId: number, repositories: string[] = []) { + // Fetch charts from each repository in parallel as separate queries + const chartQueries = useQueries({ + queries: useMemo( + () => + repositories.map((repo) => ({ + queryKey: [userId, repo, 'helm-charts'], + queryFn: () => getChartsFromRepo(repo), + enabled: !!userId && repositories.length > 0, + // one request takes a long time, so fail early to get feedback to the user faster + retries: false, + ...withGlobalError(`Unable to retrieve Helm charts from ${repo}`), + })), + [repositories, userId] + ), + }); + + // Combine the results for easier consumption by components + const allCharts = useMemo( + () => flatMap(compact(chartQueries.map((q) => q.data))), + [chartQueries] + ); + + return { + // Data from all repositories that have loaded so far + data: allCharts, + // Overall loading state + isInitialLoading: chartQueries.some((q) => q.isInitialLoading), + // Overall error state + isError: chartQueries.some((q) => q.isError), + }; +} + +async function getChartsFromRepo(repo: string): Promise { + try { + // Construct the URL with required repo parameter + const response = await axios.get('templates/helm', { + params: { repo }, + }); + + return compact( + Object.values(response.data.entries).map((versions) => + versions[0] + ? { + ...versions[0], + repo, + // versions are within this response too, so we don't need a new query to fetch versions when this is used + versions: versions.map((v) => v.version), + } + : null + ) + ); + } catch (error) { + // Ignore errors from chart repositories as some may error but others may not + return []; + } +} diff --git a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartValues.ts b/app/react/kubernetes/helm/queries/useHelmChartValues.ts similarity index 51% rename from app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartValues.ts rename to app/react/kubernetes/helm/queries/useHelmChartValues.ts index 04fa071f6..2c1a95995 100644 --- a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmChartValues.ts +++ b/app/react/kubernetes/helm/queries/useHelmChartValues.ts @@ -3,37 +3,12 @@ import { useQuery } from '@tanstack/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { withGlobalError } from '@/react-tools/react-query'; -import { queryKeys } from './query-keys'; - type Params = { - /** The name of the chart to get the values for */ chart: string; - /** The repository URL or registry ID */ repo: string; - /** The version of the chart to get the values for */ version?: string; }; -export function useHelmChartValues(params: Params, isLatestVersion = false) { - const hasValidRepoUrl = !!params.repo; - return useQuery({ - queryKey: queryKeys.chartValues( - params.repo, - params.chart, - // if the latest version is fetched, use the latest version key to cache the latest version - isLatestVersion ? 'latest' : params.version || 'latest' - ), - queryFn: () => getHelmChartValues(params), - enabled: !!params.chart && hasValidRepoUrl, - select: (data) => ({ - values: data, - }), - retry: 1, - staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often - ...withGlobalError('Unable to get Helm chart values'), - }); -} - async function getHelmChartValues(params: Params) { try { const response = await axios.get(`/templates/helm/values`, { @@ -41,6 +16,19 @@ async function getHelmChartValues(params: Params) { }); return response.data; } catch (err) { - throw parseAxiosError(err, 'Unable to get Helm chart values'); + throw parseAxiosError(err as Error, 'Unable to get Helm chart values'); } } + +export function useHelmChartValues(params: Params) { + return useQuery({ + queryKey: ['helm-chart-values', params.repo, params.chart, params.version], + queryFn: () => getHelmChartValues(params), + enabled: !!params.chart && !!params.repo, + select: (data) => ({ + values: data, + }), + staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often + ...withGlobalError('Unable to get Helm chart values'), + }); +} diff --git a/app/react/kubernetes/helm/queries/useHelmRegistries.ts b/app/react/kubernetes/helm/queries/useHelmRegistries.ts new file mode 100644 index 000000000..f48fb72fa --- /dev/null +++ b/app/react/kubernetes/helm/queries/useHelmRegistries.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { compact } from 'lodash'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { UserId } from '@/portainer/users/types'; +import { withGlobalError } from '@/react-tools/react-query'; +import { useCurrentUser } from '@/react/hooks/useUser'; + +import { HelmRegistriesResponse } from '../types'; + +/** + * Hook to fetch all Helm registries for the current user + */ +export function useHelmRegistries() { + const { user } = useCurrentUser(); + return useQuery( + ['helm', 'registries'], + async () => getHelmRegistries(user.Id), + { + enabled: !!user.Id, + ...withGlobalError('Unable to retrieve helm registries'), + } + ); +} + +/** + * Get Helm registries for user + */ +async function getHelmRegistries(userId: UserId) { + try { + const { data } = await axios.get( + `users/${userId}/helm/repositories` + ); + const repos = compact([ + // compact will remove the global repository if it's empty + data.GlobalRepository.toLowerCase(), + ...data.UserRepositories.map((repo) => repo.URL.toLowerCase()), + ]); + return [...new Set(repos)]; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve helm repositories for user'); + } +} diff --git a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepoVersions.ts b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts similarity index 67% rename from app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepoVersions.ts rename to app/react/kubernetes/helm/queries/useHelmRepoVersions.ts index 6245243cd..006b74599 100644 --- a/app/react/kubernetes/helm/helmChartSourceQueries/useHelmRepoVersions.ts +++ b/app/react/kubernetes/helm/queries/useHelmRepoVersions.ts @@ -5,28 +5,19 @@ import { compact, flatMap } from 'lodash'; import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { queryKeys } from './query-keys'; - interface HelmSearch { entries: Entries; } interface Entries { - [key: string]: { version: string; appVersion: string }[]; + [key: string]: { version: string }[]; } export interface ChartVersion { - Chart?: string; Repo: string; - Label?: string; Version: string; - AppVersion?: string; } -type RepoSource = { - repo?: string; -}; - /** * React hook to get a list of available versions for a chart from specified repositories * @@ -38,21 +29,21 @@ type RepoSource = { export function useHelmRepoVersions( chart: string, staleTime: number, - repoSources: RepoSource[] = [], + repositories: string[] = [], useCache: boolean = true ) { // Fetch versions from each repository in parallel as separate queries const versionQueries = useQueries({ queries: useMemo( () => - repoSources.map(({ repo }) => ({ - queryKey: queryKeys.chartVersions(repo || '', chart), - queryFn: () => getSearchHelmRepo({ repo, chart, useCache }), - enabled: !!chart && !!repo, + repositories.map((repo) => ({ + queryKey: ['helm', 'repositories', chart, repo, useCache], + queryFn: () => getSearchHelmRepo(repo, chart, useCache), + enabled: !!chart && repositories.length > 0, staleTime, ...withGlobalError(`Unable to retrieve versions from ${repo}`), })), - [repoSources, chart, staleTime, useCache] + [repositories, chart, staleTime, useCache] ), }); @@ -64,37 +55,30 @@ export function useHelmRepoVersions( return { data: allVersions, - isInitialLoading: versionQueries.some((q) => q.isInitialLoading), + isInitialLoading: versionQueries.some((q) => q.isLoading), isError: versionQueries.some((q) => q.isError), isFetching: versionQueries.some((q) => q.isFetching), refetch: () => Promise.all(versionQueries.map((q) => q.refetch())), }; } -type SearchRepoParams = { - repo?: string; - chart: string; - useCache?: boolean; -}; - /** * Get Helm repositories for user */ async function getSearchHelmRepo( - params: SearchRepoParams + repo: string, + chart: string, + useCache: boolean = true ): Promise { try { const { data } = await axios.get(`templates/helm`, { - params, + params: { repo, chart, useCache }, }); - // if separated by '/', take the last part - const chartKey = params.chart.split('/').pop() || params.chart; - const versions = data.entries[chartKey]; + const versions = data.entries[chart]; return ( versions?.map((v) => ({ - Repo: params.repo ?? '', + Repo: repo, Version: v.version, - AppVersion: v.appVersion, })) ?? [] ); } catch (err) { diff --git a/app/react/kubernetes/helm/helmReleaseQueries/useUpdateHelmReleaseMutation.ts b/app/react/kubernetes/helm/queries/useUpdateHelmReleaseMutation.ts similarity index 63% rename from app/react/kubernetes/helm/helmReleaseQueries/useUpdateHelmReleaseMutation.ts rename to app/react/kubernetes/helm/queries/useUpdateHelmReleaseMutation.ts index 689ebac3a..f2278ae58 100644 --- a/app/react/kubernetes/helm/helmReleaseQueries/useUpdateHelmReleaseMutation.ts +++ b/app/react/kubernetes/helm/queries/useUpdateHelmReleaseMutation.ts @@ -7,48 +7,30 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { HelmRelease, UpdateHelmReleasePayload } from '../types'; -import { queryKeys } from './query-keys'; - export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: UpdateHelmReleasePayload) => updateHelmRelease(environmentId, payload), ...withInvalidate(queryClient, [ - queryKeys.releases(environmentId), + [environmentId, 'helm', 'releases'], applicationsQueryKeys.applications(environmentId), ]), - ...withGlobalError('Unable to update Helm release'), + ...withGlobalError('Unable to uninstall helm application'), }); } -type UpdateHelmReleaseParams = { - dryRun?: boolean; -}; - -type UpdateHelmReleaseOptions = { - errorMessage?: string; -}; - -export async function updateHelmRelease( +async function updateHelmRelease( environmentId: EnvironmentId, - payload: UpdateHelmReleasePayload, - params: UpdateHelmReleaseParams = {}, - options: UpdateHelmReleaseOptions = {} + payload: UpdateHelmReleasePayload ) { try { const { data } = await axios.post( `endpoints/${environmentId}/kubernetes/helm`, - payload, - { - params, - } + payload ); return data; } catch (err) { - throw parseAxiosError( - err, - options.errorMessage ?? 'Unable to update helm release' - ); + throw parseAxiosError(err, 'Unable to update helm release'); } } diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts index 208745944..3094f84b2 100644 --- a/app/react/kubernetes/helm/types.ts +++ b/app/react/kubernetes/helm/types.ts @@ -91,7 +91,7 @@ export interface HelmChartResponse { versions: string[]; } -export interface HelmRegistryResponse { +export interface HelmRepositoryResponse { Id: number; UserId: number; URL: string; @@ -99,7 +99,7 @@ export interface HelmRegistryResponse { export interface HelmRegistriesResponse { GlobalRepository: string; - UserRepositories: HelmRegistryResponse[]; + UserRepositories: HelmRepositoryResponse[]; } export interface HelmChartsResponse { @@ -108,13 +108,21 @@ export interface HelmChartsResponse { generated: string; } +export interface InstallChartPayload { + Name: string; + Repo: string; + Chart: string; + Values: string; + Namespace: string; + Version?: string; +} + export interface UpdateHelmReleasePayload { namespace: string; values?: string; - repo: string; + repo?: string; name: string; chart: string; - appVersion?: string; version?: string; atomic?: boolean; } diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts index f27ffe642..dbacc4f4c 100644 --- a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts +++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/queries/useClusterRoles.ts @@ -21,7 +21,7 @@ export function useClusterRoles( }, { ...withGlobalError('Unable to get cluster roles'), - refetchInterval: options?.autoRefreshRate, + ...options, } ); } diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx index 2d1977a0a..04e6e155f 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx index f979240e8..60a673eb0 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const schedule = columnHelper.accessor((row) => row.Schedule, { header: 'Schedule', id: 'schedule', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx index 3b262b93b..54bb35a79 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const timezone = columnHelper.accessor((row) => row.Timezone, { header: 'Timezone', id: 'timezone', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx index 2d1977a0a..04e6e155f 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx index b189d5a7a..23cfcaf34 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const duration = columnHelper.accessor((row) => row.Duration, { header: 'Duration', id: 'duration', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx index cf8e5b5e3..b3bcb4e0f 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const finished = columnHelper.accessor((row) => row.FinishTime, { header: 'Finished', id: 'finished', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx index 8ea383c64..eff7f2465 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx @@ -7,6 +7,6 @@ export const started = columnHelper.accessor( { header: 'Started', id: 'started', - cell: ({ getValue }) => getValue() ?? '', + cell: ({ getValue }) => getValue(), } ); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx index 93ecb66a2..2f6ad1768 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx @@ -26,7 +26,7 @@ function Cell({ row: { original: item } }: CellContext) { }, ])} /> - {item.Status ?? ''} + {item.Status} {item.Status === 'Failed' && ( remainingNamespaces ); diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx index ba5eaf159..541c2315f 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx @@ -1,17 +1,13 @@ import _ from 'lodash'; import { useMemo } from 'react'; -import { AlertTriangle } from 'lucide-react'; import { isoDate } from '@/portainer/filters/filters'; import { useAuthorizations } from '@/react/hooks/useUser'; -import { pluralize } from '@/portainer/helpers/strings'; import { Link } from '@@/Link'; import { StatusBadge } from '@@/StatusBadge'; import { Badge } from '@@/Badge'; import { SystemBadge } from '@@/Badge/SystemBadge'; -import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; -import { Icon } from '@@/Icon'; import { helper } from './helper'; import { actions } from './actions'; @@ -49,34 +45,12 @@ export function useColumns() { }), helper.accessor('Status', { header: 'Status', - cell({ getValue, row: { original: item } }) { + cell({ getValue }) { const status = getValue(); return ( -
- - {status.phase} - - {item.UnhealthyEventCount > 0 && ( - - - - - - {item.UnhealthyEventCount}{' '} - {pluralize(item.UnhealthyEventCount, 'warning')} - - - - - )} -
+ + {status.phase} + ); function getColor(status?: string) { diff --git a/app/react/kubernetes/namespaces/queries/queryKeys.ts b/app/react/kubernetes/namespaces/queries/queryKeys.ts index 6c0a04676..ecfe4ea58 100644 --- a/app/react/kubernetes/namespaces/queries/queryKeys.ts +++ b/app/react/kubernetes/namespaces/queries/queryKeys.ts @@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; export const queryKeys = { list: ( environmentId: EnvironmentId, - options?: { withResourceQuota?: boolean; withUnhealthyEvents?: boolean } + options?: { withResourceQuota?: boolean } ) => compact([ 'environments', @@ -13,7 +13,6 @@ export const queryKeys = { 'kubernetes', 'namespaces', options?.withResourceQuota, - options?.withUnhealthyEvents, ]), namespace: (environmentId: EnvironmentId, namespace: string) => [ diff --git a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index e7977db84..6e521e0aa 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -8,32 +8,20 @@ import { PortainerNamespace } from '../types'; import { queryKeys } from './queryKeys'; -export function useNamespacesQuery( +export function useNamespacesQuery( environmentId: EnvironmentId, - options?: { - autoRefreshRate?: number; - withResourceQuota?: boolean; - withUnhealthyEvents?: boolean; - select?: (namespaces: PortainerNamespace[]) => T; - } + options?: { autoRefreshRate?: number; withResourceQuota?: boolean } ) { return useQuery( queryKeys.list(environmentId, { withResourceQuota: !!options?.withResourceQuota, - withUnhealthyEvents: !!options?.withUnhealthyEvents, }), - async () => - getNamespaces( - environmentId, - options?.withResourceQuota, - options?.withUnhealthyEvents - ), + async () => getNamespaces(environmentId, options?.withResourceQuota), { ...withGlobalError('Unable to get namespaces.'), refetchInterval() { return options?.autoRefreshRate ?? false; }, - select: options?.select, } ); } @@ -41,13 +29,9 @@ export function useNamespacesQuery( // getNamespaces is used to retrieve namespaces using the Portainer backend with caching export async function getNamespaces( environmentId: EnvironmentId, - withResourceQuota?: boolean, - withUnhealthyEvents?: boolean + withResourceQuota?: boolean ) { - const params = { - withResourceQuota, - withUnhealthyEvents, - }; + const params = withResourceQuota ? { withResourceQuota } : {}; try { const { data: namespaces } = await axios.get( `kubernetes/${environmentId}/namespaces`, diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index f3626e675..ba4abb744 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -10,7 +10,6 @@ export interface PortainerNamespace { Id: string; Name: string; Status: NamespaceStatus; - UnhealthyEventCount: number; Annotations: Record | null; CreationDate: string; NamespaceOwner: string; diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx deleted file mode 100644 index e8539826c..000000000 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { HttpResponse } from 'msw'; - -import { withTestRouter } from '@/react/test-utils/withRouter'; -import { withUserProvider } from '@/react/test-utils/withUserProvider'; -import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { createMockUsers } from '@/react-tools/test-mocks'; -import { server, http } from '@/setup-tests/server'; - -import { ServicesDatatable } from './ServicesDatatable'; - -vi.mock('@/react/hooks/useEnvironmentId', () => ({ - useEnvironmentId: () => 1, -})); - -vi.mock('@/portainer/services/notifications', () => ({ - notifyError: vi.fn(), - notifySuccess: vi.fn(), -})); -function createMockServices(count: number) { - return Array.from({ length: count }, (_, i) => { - let namespace = 'default'; - if (i % 3 === 0) { - namespace = 'kube-system'; - } else if (i % 2 !== 0) { - namespace = 'my-namespace'; - } - - let type = 'ClusterIP'; - if (i % 4 === 1) { - type = 'NodePort'; - } else if (i % 4 === 2) { - type = 'LoadBalancer'; - } else if (i % 4 === 3) { - type = 'ExternalName'; - } - - return { - UID: `service-${i}`, - Name: `service-${i}`, - Namespace: namespace, - Type: type, - Ports: [{ Port: 80 + i, TargetPort: 8080 + i, Protocol: 'TCP' }], - Selector: { app: `app-${i}` }, - CreationTimestamp: new Date(Date.now() - i * 1000 * 60).toISOString(), - ApplicationOwner: '', - Applications: [{ Name: `app-${i}` }], - }; - }); -} - -const mockServices = createMockServices(4); - -const mockNamespaces = [ - { - Name: 'default', - IsSystem: false, - Status: 'Active', - CreationTimestamp: '2024-01-01T00:00:00Z', - }, - { - Name: 'kube-system', - IsSystem: true, - Status: 'Active', - CreationTimestamp: '2024-01-01T00:00:00Z', - }, - { - Name: 'my-namespace', - IsSystem: false, - Status: 'Active', - CreationTimestamp: '2024-01-01T00:00:00Z', - }, -]; - -beforeEach(() => { - server.use( - http.get('/api/kubernetes/1/services', () => - HttpResponse.json(mockServices) - ), - http.get('/api/kubernetes/1/namespaces', () => - HttpResponse.json(mockNamespaces) - ) - ); -}); -const mockUser = { - // Admin user - ...createMockUsers(1, 1)[0], -}; - -function createTestComponent() { - return withTestRouter( - withUserProvider(withTestQueryProvider(ServicesDatatable), mockUser), - { - route: '/kubernetes/services', - stateConfig: [ - { - name: 'kubernetes.services', - url: '/kubernetes/services', - params: { endpointId: '1' }, - }, - ], - } - ); -} - -describe('ServicesDatatable', () => { - it('renders services data correctly', async () => { - const TestComponent = createTestComponent(); - render(); - - expect(await screen.findByText('service-1')).toBeInTheDocument(); - expect(screen.getByText('service-2')).toBeInTheDocument(); - }); - - it('should filter system resources correctly when toggled', async () => { - const TestComponent = createTestComponent(); - render(); - - const settingsButton = screen.getByRole('button', { name: /settings/i }); - await userEvent.click(settingsButton); - - await waitFor(() => { - expect(screen.queryByText('service-0')).not.toBeInTheDocument(); - }); - - const systemToggle = await screen.findByTestId('show-system-resources'); - await userEvent.click(systemToggle); - - await waitFor(() => { - expect(screen.queryByText('service-0')).toBeInTheDocument(); - }); - - expect(screen.getByText('service-3')).toBeInTheDocument(); - expect(screen.getByText('service-1')).toBeInTheDocument(); - expect(screen.getByText('service-2')).toBeInTheDocument(); - }); - - it('should show loading state when data is loading', async () => { - const TestComponent = createTestComponent(); - render(); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); -}); diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx index 265808564..4786eaf2f 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx @@ -16,7 +16,6 @@ import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultD import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; -import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; @@ -26,6 +25,7 @@ import { useMutationDeleteServices, useClusterServices } from '../../service'; import { Service } from '../../types'; import { columns } from './columns'; +import { createStore } from './datatable-store'; import { ServiceRowData } from './types'; const storageKey = 'k8sServicesDatatable'; @@ -34,53 +34,52 @@ const settingsStore = createStore(storageKey); export function ServicesDatatable() { const tableState = useTableState(settingsStore, storageKey); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( - environmentId, - { - select: (namespacesArray) => - namespacesArray?.reduce>( - (acc, namespace) => { - acc[namespace.Name] = namespace; - return acc; - }, - {} - ), - } - ); - const { authorized: canWrite } = useAuthorizations(['K8sServiceW']); - const { authorized: canAccessSystemResources } = useAuthorizations( - 'K8sAccessSystemNamespaces' - ); + const { data: namespacesArray, ...namespacesQuery } = + useNamespacesQuery(environmentId); const { data: services, ...servicesQuery } = useClusterServices( environmentId, { autoRefreshRate: tableState.autoRefreshRate * 1000, withApplications: true, - select: (services) => - services?.filter( - (service) => - (canAccessSystemResources && tableState.showSystemResources) || - !namespaces?.[service.Namespace]?.IsSystem - ), } ); - const servicesWithIsSystem = useServicesRowData(services || [], namespaces); + const namespaces: Record = {}; + if (Array.isArray(namespacesArray)) { + for (let i = 0; i < namespacesArray.length; i++) { + const namespace = namespacesArray[i]; + namespaces[namespace.Name] = namespace; + } + } + + const { authorized: canWrite } = useAuthorizations(['K8sServiceW']); + const readOnly = !canWrite; + const { authorized: canAccessSystemResources } = useAuthorizations( + 'K8sAccessSystemNamespaces' + ); + const filteredServices = services?.filter( + (service) => + (canAccessSystemResources && tableState.showSystemResources) || + !namespaces?.[service.Namespace]?.IsSystem + ); + + const servicesWithIsSystem = useServicesRowData( + filteredServices || [], + namespaces + ); return ( row.UID} isRowSelectable={(row) => !namespaces?.[row.original.Namespace]?.IsSystem} - disableSelect={!canWrite} + disableSelect={readOnly} renderTableActions={(selectedRows) => ( )} diff --git a/app/react/kubernetes/services/service.ts b/app/react/kubernetes/services/service.ts index a7cdbc7f2..a36f25db9 100644 --- a/app/react/kubernetes/services/service.ts +++ b/app/react/kubernetes/services/service.ts @@ -23,21 +23,18 @@ export const queryKeys = { * * @returns The result of the query. */ -export function useClusterServices( +export function useClusterServices( environmentId: EnvironmentId, - options?: { - autoRefreshRate?: number; - withApplications?: boolean; - select?: (services: Service[]) => T; - } + options?: { autoRefreshRate?: number; withApplications?: boolean } ) { return useQuery( queryKeys.clusterServices(environmentId), async () => getClusterServices(environmentId, options?.withApplications), { ...withGlobalError('Unable to get services.'), - refetchInterval: options?.autoRefreshRate, - select: options?.select, + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, } ); } diff --git a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx index 032fe63aa..b81f60e9a 100644 --- a/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx +++ b/app/react/kubernetes/volumes/ListView/VolumesDatatable.tsx @@ -16,17 +16,12 @@ import { } from '../../datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '../../datatables/SystemResourceDescription'; import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; -import { - convertToVolumeViewModels, - useAllVolumesQuery, -} from '../queries/useVolumesQuery'; +import { useAllVolumesQuery } from '../queries/useVolumesQuery'; import { isSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace'; import { useDeleteVolumes } from '../queries/useDeleteVolumes'; import { isVolumeUsed } from '../utils'; -import { K8sVolumeInfo } from '../types'; import { columns } from './columns'; -import { VolumeViewModel } from './types'; export function VolumesDatatable() { const tableState = useTableStateWithStorage( @@ -50,15 +45,21 @@ export function VolumesDatatable() { const namespaces = namespaceListQuery.data ?? []; const volumesQuery = useAllVolumesQuery(envId, { refetchInterval: tableState.autoRefreshRate * 1000, - select: transformAndFilterVolumes, }); const volumes = volumesQuery.data ?? []; + const filteredVolumes = tableState.showSystemResources + ? volumes + : volumes.filter( + (volume) => + !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) + ); + return ( ); - - function transformAndFilterVolumes( - volumes: K8sVolumeInfo[] - ): VolumeViewModel[] { - const transformedVolumes = convertToVolumeViewModels(volumes); - return transformedVolumes.filter( - (volume) => - tableState.showSystemResources || - !isSystemNamespace(volume.ResourcePool.Namespace.Name, namespaces) - ); - } } diff --git a/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts index 7732c2b58..78d5a295d 100644 --- a/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts +++ b/app/react/kubernetes/volumes/queries/useDeleteVolumes.ts @@ -36,7 +36,7 @@ export function useDeleteVolumes(environmentId: EnvironmentId) { ); } queryClient.invalidateQueries(queryKeys.storages(environmentId)); - return queryClient.invalidateQueries(queryKeys.volumes(environmentId)); + queryClient.invalidateQueries(queryKeys.volumes(environmentId)); }, ...withGlobalError('Unable to remove volumes'), }); diff --git a/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts b/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts new file mode 100644 index 000000000..cdf6b6ee0 --- /dev/null +++ b/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withGlobalError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { K8sVolumeInfo } from '../types'; + +import { queryKeys } from './query-keys'; +import { convertToVolumeViewModels } from './useVolumesQuery'; + +// useQuery to get a list of all volumes in a cluster +export function useNamespaceVolumes( + environmentId: EnvironmentId, + namespace: string, + queryOptions?: { + refetchInterval?: number; + withApplications?: boolean; + } +) { + return useQuery( + queryKeys.volumes(environmentId), + () => + getNamespaceVolumes(environmentId, namespace, { + withApplications: queryOptions?.withApplications ?? false, + }), + { + enabled: !!namespace, + refetchInterval: queryOptions?.refetchInterval, + select: convertToVolumeViewModels, + ...withGlobalError('Unable to retrieve volumes'), + } + ); +} + +// get all volumes in a cluster +async function getNamespaceVolumes( + environmentId: EnvironmentId, + namespace: string, + params?: { withApplications: boolean } +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/namespaces/${namespace}/volumes`, + { params } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve volumes'); + } +} diff --git a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts index 646065758..94b30345b 100644 --- a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts +++ b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts @@ -14,11 +14,10 @@ import { appOwnerLabel } from '../../applications/constants'; import { queryKeys } from './query-keys'; // useQuery to get a list of all volumes in a cluster -export function useAllVolumesQuery( +export function useAllVolumesQuery( environmentId: EnvironmentId, queryOptions?: { refetchInterval?: number; - select?: (volumes: K8sVolumeInfo[]) => T[]; } ) { return useQuery( @@ -26,7 +25,7 @@ export function useAllVolumesQuery( () => getAllVolumes(environmentId, { withApplications: true }), { refetchInterval: queryOptions?.refetchInterval, - select: queryOptions?.select, + select: convertToVolumeViewModels, ...withGlobalError('Unable to retrieve volumes'), } ); diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx index 393c55f9c..f2b20857d 100644 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx @@ -7,7 +7,6 @@ import { trackEvent } from '@/angulartics.matomo/analytics-services'; import { Query } from '@/react/portainer/environments/queries/useEnvironmentList'; import { Button } from '@@/buttons'; -import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { KubeconfigPrompt } from './KubeconfigPrompt'; @@ -24,41 +23,23 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) { isKubernetesEnvironment(env.Type) ); - const isHttp = window.location.protocol === 'http:'; - const noKubeEnvs = kubeEnvs.length === 0; - const isDisabled = noKubeEnvs || isHttp; - - let tooltipMessage = ''; - if (isHttp) { - tooltipMessage = - 'Kubeconfig download is not available when Portainer is accessed via HTTP. Please use HTTPS'; - } else if (noKubeEnvs) { - tooltipMessage = 'No Kubernetes environments detected'; + if (!isKubeconfigButtonVisible()) { + return null; } - const button = ( - - ); - return ( <> - {isDisabled ? ( - - {button} - - ) : ( - button - )} + {prompt()} ); @@ -79,6 +60,10 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) { setIsOpen(false); } + function isKubeconfigButtonVisible() { + return window.location.protocol === 'https:'; + } + function prompt() { return ( isOpen && ( diff --git a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx index f53015cea..725e115cd 100644 --- a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx +++ b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx @@ -201,10 +201,8 @@ function InheritanceMessage({ return ( -
- - {children} -
+ + {children} diff --git a/app/react/portainer/account/AccountView/.keep b/app/react/portainer/account/AccountView/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx index bad68c293..7ed2f4651 100644 --- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatable.tsx @@ -81,25 +81,21 @@ export function HelmRepositoryDatatable() { function HelmDatatableDescription({ isAdmin }: { isAdmin: boolean }) { return ( -

- Adding a Helm repository here makes it available only in your Portainer - user account. The Helm charts from these repositories (along with the - globally set Helm repository) are shown in the 'Create from - Code' screen. -

- {isAdmin && ( - <> - To manage your helm repositories globally, navigate to{' '} - - Settings > General - - . - + Adding a Helm repo here only makes it available in your own user + account's Portainer UI. Helm charts are pulled down from these repos + (plus the{' '} + {isAdmin ? ( + + globally-set Helm repo + + ) : ( + globally-set Helm repo )} + ) and shown in the Create from code screen's Helm charts list.
); } diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts index 8916dfb41..c12ed9ffa 100644 --- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts @@ -4,8 +4,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { success as notifySuccess } from '@/portainer/services/notifications'; import { withError } from '@/react-tools/react-query'; import { pluralize } from '@/portainer/helpers/strings'; -import { queryKeys } from '@/react/kubernetes/helm/helmChartSourceQueries/query-keys'; -import { useCurrentUser } from '@/react/hooks/useUser'; import { CreateHelmRepositoryPayload, @@ -54,12 +52,11 @@ export async function deleteHelmRepositories(repos: HelmRepository[]) { export function useDeleteHelmRepositoryMutation() { const queryClient = useQueryClient(); - const { user } = useCurrentUser(); return useMutation(deleteHelmRepository, { onSuccess: (_, helmRepository) => { notifySuccess('Helm repository deleted successfully', helmRepository.URL); - return queryClient.invalidateQueries(queryKeys.registries(user.Id)); + return queryClient.invalidateQueries(['helmrepositories']); }, ...withError('Unable to delete Helm repository'), }); @@ -67,7 +64,6 @@ export function useDeleteHelmRepositoryMutation() { export function useDeleteHelmRepositoriesMutation() { const queryClient = useQueryClient(); - const { user } = useCurrentUser(); return useMutation(deleteHelmRepositories, { onSuccess: () => { @@ -79,31 +75,26 @@ export function useDeleteHelmRepositoriesMutation() { 'repositories' )} deleted successfully` ); - return queryClient.invalidateQueries(queryKeys.registries(user.Id)); + return queryClient.invalidateQueries(['helmrepositories']); }, ...withError('Unable to delete Helm repositories'), }); } export function useHelmRepositories(userId: number) { - return useQuery( - queryKeys.registries(userId), - () => getHelmRepositories(userId), - { - staleTime: 20, - ...withError('Unable to retrieve Helm repositories'), - } - ); + return useQuery(['helmrepositories'], () => getHelmRepositories(userId), { + staleTime: 20, + ...withError('Unable to retrieve Helm repositories'), + }); } export function useCreateHelmRepositoryMutation() { const queryClient = useQueryClient(); - const { user } = useCurrentUser(); return useMutation(createHelmRepository, { onSuccess: (_, payload) => { notifySuccess('Helm repository created successfully', payload.URL); - return queryClient.invalidateQueries(queryKeys.registries(user.Id)); + return queryClient.invalidateQueries(['helmrepositories']); }, ...withError('Unable to create Helm repository'), }); diff --git a/app/react/portainer/account/AccountView/theme-options.tsx b/app/react/portainer/account/AccountView/theme-options.tsx index 21c1d00d9..c76ddaa2f 100644 --- a/app/react/portainer/account/AccountView/theme-options.tsx +++ b/app/react/portainer/account/AccountView/theme-options.tsx @@ -7,24 +7,28 @@ export const options = [ id: 'light', icon: , label: 'Light Theme', + description: 'Default color mode', value: 'light', }, { id: 'dark', icon: , label: 'Dark Theme', + description: 'Dark color mode', value: 'dark', }, { id: 'highcontrast', icon: , label: 'High Contrast', + description: 'High contrast color mode', value: 'highcontrast', }, { id: 'auto', icon: , - label: 'System Theme', + label: 'Auto', + description: 'Sync with system theme', value: 'auto', }, ]; diff --git a/app/react/portainer/environments/queries/query-keys.ts b/app/react/portainer/environments/queries/query-keys.ts index 5cb21a348..ed0600c19 100644 --- a/app/react/portainer/environments/queries/query-keys.ts +++ b/app/react/portainer/environments/queries/query-keys.ts @@ -3,11 +3,6 @@ import { EnvironmentId } from '../types'; export const environmentQueryKeys = { base: () => ['environments'] as const, item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const, - registries: (environmentId: EnvironmentId, namespace?: string) => - [ - ...environmentQueryKeys.base(), - environmentId, - 'registries', - namespace, - ] as const, + registries: (environmentId: EnvironmentId) => + [...environmentQueryKeys.base(), environmentId, 'registries'] as const, }; diff --git a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts index c25ffa157..19588b5ca 100644 --- a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts +++ b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts @@ -14,28 +14,17 @@ export function useEnvironmentRegistries>( environmentId: EnvironmentId, queryOptions: GenericRegistriesQueryOptions = {} ) { - const { namespace } = queryOptions; return useGenericRegistriesQuery( - environmentQueryKeys.registries(environmentId, namespace), - () => getEnvironmentRegistries(environmentId, { namespace }), + environmentQueryKeys.registries(environmentId), + () => getEnvironmentRegistries(environmentId), queryOptions ); } -type Params = { - namespace?: string; -}; - -async function getEnvironmentRegistries( - environmentId: EnvironmentId, - params: Params -) { +async function getEnvironmentRegistries(environmentId: EnvironmentId) { try { const { data } = await axios.get>( - buildUrl(environmentId, 'registries'), - { - params, - } + buildUrl(environmentId, 'registries') ); return data; } catch (err) { diff --git a/app/react/portainer/registries/CreateView/options.tsx b/app/react/portainer/registries/CreateView/options.tsx index 984eaa892..b6802e60b 100644 --- a/app/react/portainer/registries/CreateView/options.tsx +++ b/app/react/portainer/registries/CreateView/options.tsx @@ -1,56 +1,62 @@ -import { BadgeIcon } from '@@/BadgeIcon'; +import { Edit } from 'lucide-react'; -import { RegistryTypes } from '../types/registry'; -import { registryIconMap, registryLabelMap } from '../utils/constants'; +import Docker from '@/assets/ico/vendor/docker.svg?c'; +import Ecr from '@/assets/ico/vendor/ecr.svg?c'; +import Quay from '@/assets/ico/vendor/quay.svg?c'; +import Proget from '@/assets/ico/vendor/proget.svg?c'; +import Azure from '@/assets/ico/vendor/azure.svg?c'; +import Gitlab from '@/assets/ico/vendor/gitlab.svg?c'; + +import { BadgeIcon } from '@@/BadgeIcon'; export const options = [ { id: 'registry_dockerhub', - icon: registryIconMap[RegistryTypes.DOCKERHUB], - label: registryLabelMap[RegistryTypes.DOCKERHUB], + icon: Docker, + label: 'DockerHub', description: 'DockerHub authenticated account', - value: String(RegistryTypes.DOCKERHUB), + value: '6', }, { id: 'registry_aws_ecr', - icon: registryIconMap[RegistryTypes.ECR], - label: registryLabelMap[RegistryTypes.ECR], + icon: Ecr, + label: 'AWS ECR', description: 'Amazon elastic container registry', - value: String(RegistryTypes.ECR), + value: '7', }, { id: 'registry_quay', - icon: registryIconMap[RegistryTypes.QUAY], - label: registryLabelMap[RegistryTypes.QUAY], + icon: Quay, + label: 'Quay.io', description: 'Quay container registry', - value: String(RegistryTypes.QUAY), + value: '1', }, { id: 'registry_proget', - icon: registryIconMap[RegistryTypes.PROGET], - label: registryLabelMap[RegistryTypes.PROGET], + icon: Proget, + label: 'ProGet', description: 'ProGet container registry', - value: String(RegistryTypes.PROGET), + value: '5', }, { id: 'registry_azure', - icon: registryIconMap[RegistryTypes.AZURE], - label: registryLabelMap[RegistryTypes.AZURE], + icon: Azure, + label: 'Azure', description: 'Azure container registry', - value: String(RegistryTypes.AZURE), + value: '2', }, { id: 'registry_gitlab', - icon: registryIconMap[RegistryTypes.GITLAB], - label: registryLabelMap[RegistryTypes.GITLAB], + icon: Gitlab, + label: 'GitLab', description: 'GitLab container registry', - value: String(RegistryTypes.GITLAB), + value: '4', }, { id: 'registry_custom', - icon: , - label: registryLabelMap[RegistryTypes.CUSTOM], + icon: , + label: 'Custom registry', description: 'Define your own registry', - value: String(RegistryTypes.CUSTOM), + value: '3', }, ]; diff --git a/app/react/portainer/registries/queries/build-url.ts b/app/react/portainer/registries/queries/build-url.ts index f490ec406..3f76215bd 100644 --- a/app/react/portainer/registries/queries/build-url.ts +++ b/app/react/portainer/registries/queries/build-url.ts @@ -1,17 +1,13 @@ import { RegistryId } from '../types/registry'; -export function buildUrl(registryId: RegistryId, resource?: 'repositories') { - let url = '/registries'; +export function buildUrl(registryId: RegistryId) { + const base = '/registries'; if (registryId) { - url += `/${registryId}`; + return `${base}/${registryId}`; } - if (resource) { - url += `/${resource}`; - } - - return url; + return base; } export function buildProxyUrl(registryId: RegistryId) { diff --git a/app/react/portainer/registries/queries/useRegistries.ts b/app/react/portainer/registries/queries/useRegistries.ts index 4ea5aad6e..007ab70cb 100644 --- a/app/react/portainer/registries/queries/useRegistries.ts +++ b/app/react/portainer/registries/queries/useRegistries.ts @@ -24,8 +24,6 @@ export type GenericRegistriesQueryOptions = { onSuccess?: (data: T) => void; /** is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this. */ hideDefault?: boolean; - /** is used to filter the registries by namespace. Kubernetes views use this. */ - namespace?: string; }; export function useGenericRegistriesQuery( diff --git a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx deleted file mode 100644 index 6ec8599e1..000000000 --- a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { vi } from 'vitest'; - -import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; -import { withTestRouter } from '@/react/test-utils/withRouter'; - -import { TagsDatatable } from './TagsDatatable'; -import { Tag } from './types'; -import { RepositoryTagViewModel } from './view-model'; - -// Mock the necessary hooks -const mockUseCurrentStateAndParams = vi.fn(); - -vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ - ...(await importOriginal()), - useCurrentStateAndParams: () => mockUseCurrentStateAndParams(), -})); - -// Mock the Link component to capture route parameters and generate proper hrefs -vi.mock('@@/Link', () => ({ - Link: ({ - children, - params, - 'data-cy': dataCy, - title, - }: { - children: React.ReactNode; - params?: Record; - 'data-cy'?: string; - title?: string; - }) => { - // Simulate href generation based on route and params - // For 'portainer.registries.registry.repository.tag' route - const baseParams = { - endpointId: '1', - id: '1', - repository: 'test-repo', - ...params, - }; - - const tag = (baseParams as Record).tag || ''; - const href = `/endpoints/${baseParams.endpointId}/registries/${baseParams.id}/repositories/${baseParams.repository}/tags/${tag}`; - - return ( - - {children} - - ); - }, -})); - -vi.mock('../../queries/useTagDetails', () => ({ - useTagDetails: vi.fn( - ( - params, - { select }: { select?: (data: RepositoryTagViewModel) => string } = {} - ) => { - const data: RepositoryTagViewModel = { - Name: params.tag, - Os: 'linux', - Architecture: 'amd64', - ImageId: `sha256:${params.tag}123`, - Size: 1024, - ImageDigest: '', - ManifestV2: { - digest: `sha256:${params.tag}123`, - schemaVersion: 2, - mediaType: 'application/vnd.docker.distribution.manifest.v2+json', - config: { - digest: `sha256:${params.tag}123`, - mediaType: 'application/vnd.docker.container.image.v1+json', - size: 1024, - }, - layers: [], - }, - History: [], - }; - - return { - data: select?.(data) || data, - isLoading: false, - error: null, - }; - } - ), -})); - -// Create mock data -const mockTags: Tag[] = [ - { Name: 'latest' }, - { Name: 'v1.0.0' }, - { Name: 'dev-branch' }, - { Name: 'feature/new-ui' }, -]; - -const defaultProps = { - dataset: mockTags, - advancedFeaturesAvailable: true, - onRemove: vi.fn(), - onRetag: vi.fn().mockResolvedValue(undefined), -}; - -function renderComponent() { - const Wrapped = withTestQueryProvider( - withTestRouter(() => ) - ); - return render(); -} - -describe('TagsDatatable', () => { - beforeEach(() => { - // Set up default mock values - mockUseCurrentStateAndParams.mockReturnValue({ - params: { - endpointId: '1', - id: '1', - repository: 'test-repo', - }, - }); - }); - - it('renders basic table structure', () => { - renderComponent(); - expect(screen.getByText('Tags')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); - }); - - it('renders tag data in table', () => { - renderComponent(); - - // Check that our mock tags are rendered somewhere in the table - expect(screen.getByText('latest')).toBeInTheDocument(); - expect(screen.getByText('v1.0.0')).toBeInTheDocument(); - expect(screen.getByText('dev-branch')).toBeInTheDocument(); - expect(screen.getByText('feature/new-ui')).toBeInTheDocument(); - }); - - it('creates correct hrefs for tag name links', () => { - renderComponent(); - - // Get the links by their data-cy attributes - const latestLink = screen.getByTestId( - 'registry-tag-name_latest' - ) as HTMLAnchorElement; - const v100Link = screen.getByTestId( - 'registry-tag-name_v1.0.0' - ) as HTMLAnchorElement; - const devBranchLink = screen.getByTestId( - 'registry-tag-name_dev-branch' - ) as HTMLAnchorElement; - const featureLink = screen.getByTestId( - 'registry-tag-name_feature/new-ui' - ) as HTMLAnchorElement; - - // Verify the exact path portion of the href - expect(new URL(latestLink.href).pathname).toBe( - '/endpoints/1/registries/1/repositories/test-repo/tags/latest' - ); - expect(new URL(v100Link.href).pathname).toBe( - '/endpoints/1/registries/1/repositories/test-repo/tags/v1.0.0' - ); - expect(new URL(devBranchLink.href).pathname).toBe( - '/endpoints/1/registries/1/repositories/test-repo/tags/dev-branch' - ); - expect(new URL(featureLink.href).pathname).toBe( - '/endpoints/1/registries/1/repositories/test-repo/tags/feature/new-ui' - ); - }); -}); diff --git a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts index bcedccb47..4f07b5bec 100644 --- a/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts +++ b/app/react/portainer/registries/repositories/ItemView/TagsDatatable/columns/useColumns.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import { humanize } from '@/portainer/filters/filters'; import { trimSHA } from '@/docker/filters/utils'; -import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn'; +import { buildNameColumn } from '@@/datatables/buildNameColumn'; import { Tag } from '../types'; @@ -13,13 +13,13 @@ import { buildCell } from './buildCell'; import { actions } from './actions'; const columns = [ - buildNameColumnFromObject({ - nameKey: 'Name', - path: 'portainer.registries.registry.repository.tag', - dataCy: 'registry-tag-name', - idParam: 'tag', - idGetter: (item) => item.Name, - }), + buildNameColumn( + 'Name', + 'portainer.registries.registry.repository.tag', + 'tag', + 'registry-tag-name', + (item) => item.Name + ), helper.display({ header: 'OS/Architecture', cell: buildCell((model) => `${model.Os}/${model.Architecture}`), diff --git a/app/react/portainer/registries/utils/constants.tsx b/app/react/portainer/registries/utils/constants.tsx deleted file mode 100644 index e20ee285f..000000000 --- a/app/react/portainer/registries/utils/constants.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Edit } from 'lucide-react'; - -import Docker from '@/assets/ico/vendor/docker.svg?c'; -import Ecr from '@/assets/ico/vendor/ecr.svg?c'; -import Quay from '@/assets/ico/vendor/quay.svg?c'; -import Proget from '@/assets/ico/vendor/proget.svg?c'; -import Azure from '@/assets/ico/vendor/azure.svg?c'; -import Gitlab from '@/assets/ico/vendor/gitlab.svg?c'; - -import { RegistryTypes } from '../types/registry'; - -export const registryLabelMap: Record = { - [RegistryTypes.ANONYMOUS]: 'Anonymous', - [RegistryTypes.DOCKERHUB]: 'DockerHub', - [RegistryTypes.ECR]: 'AWS ECR', - [RegistryTypes.QUAY]: 'Quay.io', - [RegistryTypes.PROGET]: 'ProGet', - [RegistryTypes.AZURE]: 'Azure', - [RegistryTypes.GITLAB]: 'GitLab', - [RegistryTypes.CUSTOM]: 'Custom registry', - [RegistryTypes.GITHUB]: 'GitHub', -}; - -export const registryIconMap = { - [RegistryTypes.DOCKERHUB]: Docker, - [RegistryTypes.ECR]: Ecr, - [RegistryTypes.QUAY]: Quay, - [RegistryTypes.PROGET]: Proget, - [RegistryTypes.AZURE]: Azure, - [RegistryTypes.GITLAB]: Gitlab, - [RegistryTypes.CUSTOM]: Edit, - // github and anonymous don't have an icon - [RegistryTypes.GITHUB]: null, - [RegistryTypes.ANONYMOUS]: null, -}; diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx index 0c21140aa..e5d519174 100644 --- a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx @@ -4,7 +4,7 @@ import { TextTip } from '@@/Tip/TextTip'; import { FormControl } from '@@/form-components/FormControl'; import { FormSection } from '@@/form-components/FormSection'; import { Input } from '@@/form-components/Input'; -import { ExternalLink } from '@@/ExternalLink'; +import { InsightsBox } from '@@/InsightsBox'; export function HelmSection() { const [{ name }, { error }] = useField('helmRepositoryUrl'); @@ -13,17 +13,39 @@ export function HelmSection() {
- You can specify the URL to your own{' '} - - Helm repository - {' '} - here. + official documentation + {' '} + for more details.
+ + At present Portainer does not support OCI format Helm charts. + Support for OCI charts will be available in a future release. If you + would like to provide feedback on OCI support or get access to early + releases to test this functionality,{' '} + + please get in touch + + . + + } + className="block w-fit mt-2 mb-1" + /> + { const inputElement = screen.getByDisplayValue(value.VAR1); await user.clear(inputElement); - expect(onChange).toHaveBeenCalledWith({ VAR1: undefined }); + expect(onChange).toHaveBeenCalledWith({ VAR1: '' }); const newValue = 'New Value'; await user.type(inputElement, newValue); @@ -107,14 +107,11 @@ test('validates env vars fieldset', () => { ]); const validData = { VAR1: 'Value 1', VAR2: 'Value 2' }; - const emptyData = { VAR1: '', VAR2: 'Value 2' }; - const undefinedData = { VAR1: undefined, VAR2: 'Value 2' }; + const invalidData = { VAR1: '', VAR2: 'Value 2' }; const validResult = schema.isValidSync(validData); - const emptyResult = schema.isValidSync(emptyData); - const undefinedResult = schema.isValidSync(undefinedData); + const invalidResult = schema.isValidSync(invalidData); expect(validResult).toBe(true); - expect(emptyResult).toBe(true); - expect(undefinedResult).toBe(true); + expect(invalidResult).toBe(false); }); diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx index a1281772f..dad33af07 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx @@ -6,7 +6,7 @@ import { TemplateEnv } from '@/react/portainer/templates/app-templates/types'; import { FormControl } from '@@/form-components/FormControl'; import { Input, Select } from '@@/form-components/Input'; -type Value = Record; +type Value = Record; export { type Value as EnvVarsValue }; @@ -27,7 +27,7 @@ export function EnvVarsFieldset({ handleChange(env.name, value)} errors={errors?.[env.name]} /> @@ -36,7 +36,7 @@ export function EnvVarsFieldset({ ); function handleChange(name: string, envValue: string) { - onChange({ ...values, [name]: envValue || undefined }); + onChange({ ...values, [name]: envValue }); } } @@ -55,7 +55,7 @@ function Item({ return ( @@ -101,9 +101,7 @@ export function envVarsFieldsetValidation( ): SchemaOf { return object( Object.fromEntries( - definitions - .filter((v) => !v.preset) - .map((v) => [v.name, string().optional()]) + definitions.map((v) => [v.name, string().required('Required')]) ) ); } diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts index f40d79c56..d94a2412e 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts @@ -2,6 +2,6 @@ import { AccessControlFormData } from '@/react/portainer/access-control/types'; export interface FormValues { name: string; - envVars: Record; + envVars: Record; accessControl: AccessControlFormData; } diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts index 3619caa20..94ea24bc3 100644 --- a/app/react/portainer/templates/app-templates/view-model.ts +++ b/app/react/portainer/templates/app-templates/view-model.ts @@ -88,8 +88,10 @@ function setTemplatesV3(this: TemplateViewModel, template: AppTemplate) { this.Id = template.id; } +let templateV2ID = 0; + function setTemplatesV2(this: TemplateViewModel, template: AppTemplate) { - this.Id = template.id; + this.Id = templateV2ID++; this.Title = template.title; this.Type = template.type; this.Description = template.description; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx index 9c7fa8a32..d4d4142f7 100644 --- a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx @@ -2,7 +2,7 @@ import { Meta } from '@storybook/react'; import { useMemo, useState } from 'react'; import { createMockUsers } from '@/react-tools/test-mocks'; -import { Role } from '@/portainer/users/types'; +import { Role, User } from '@/portainer/users/types'; import { UserViewModel } from '@/portainer/models/user'; import { UserContext } from '@/react/hooks/useUser'; @@ -28,7 +28,7 @@ function Example({ userRole }: Args) { () => ({ user: new UserViewModel({ Role: userRole }) }), [userRole] ); - const [users] = useState(createMockUsers(20, Role.Standard)); + const [users] = useState(createMockUsers(20) as User[]); const [memberships] = useState[]>( users diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx index efe4cc908..6f2e53f1e 100644 --- a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx @@ -28,7 +28,7 @@ function Example({ userRole }: Args) { [userRole] ); - const [users] = useState(createMockUsers(20, Role.Standard)); + const [users] = useState(createMockUsers(20)); const [roles] = useState( Object.fromEntries( users.map((user) => [ diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx index 8966d6fad..bbbcc6498 100644 --- a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx @@ -1,10 +1,6 @@ import { Meta } from '@storybook/react'; -import { useMemo } from 'react'; import { createMockUsers } from '@/react-tools/test-mocks'; -import { Role } from '@/portainer/users/types'; -import { UserContext } from '@/react/hooks/useUser'; -import { UserViewModel } from '@/portainer/models/user'; import { UsersList } from './UsersList'; @@ -17,25 +13,10 @@ export default meta; export { Example }; -interface Args { - userRole: Role; +function Example() { + const users = createMockUsers(20); + + return ; } -function Example({ userRole }: Args) { - const userProviderState = useMemo( - () => ({ user: new UserViewModel({ Role: userRole }) }), - [userRole] - ); - - const users = createMockUsers(20, Role.Standard); - - return ( - - - - ); -} - -Example.args = { - userRole: Role.Admin, -}; +Example.args = {}; diff --git a/app/react/sidebar/EnvironmentSidebar.module.css b/app/react/sidebar/EnvironmentSidebar.module.css index 2400d635a..56165048c 100644 --- a/app/react/sidebar/EnvironmentSidebar.module.css +++ b/app/react/sidebar/EnvironmentSidebar.module.css @@ -1,11 +1,12 @@ .root { background-color: var(--bg-sidebar-nav-color); + border-color: var(--border-sidebar-color); } .closeBtn { - background-color: transparent; + background-color: var(--bg-btn-default-color); } .closeBtn:hover { - background-color: var(--graphite-500); + background-color: var(--bg-btn-default-hover-color); } diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx index bea5f86c1..2e51b449c 100644 --- a/app/react/sidebar/EnvironmentSidebar.tsx +++ b/app/react/sidebar/EnvironmentSidebar.tsx @@ -36,7 +36,7 @@ export function EnvironmentSidebar() { } return ( -
+
{environment ? ( ) : ( @@ -151,7 +151,7 @@ function Title({ environment, onClear }: TitleProps) { onClick={onClear} className={clsx( styles.closeBtn, - 'ml-auto mr-2 flex h-5 w-5 items-center justify-center rounded border-0 p-1 text-sm text-white transition-colors duration-200' + 'ml-auto mr-2 flex h-5 w-5 items-center justify-center rounded border-0 p-1 text-sm text-gray-5 transition-colors duration-200 hover:text-white be:text-gray-6 be:hover:text-white' )} > diff --git a/app/react/sidebar/Footer/Footer.tsx b/app/react/sidebar/Footer/Footer.tsx index 7374857c5..0c6636a3e 100644 --- a/app/react/sidebar/Footer/Footer.tsx +++ b/app/react/sidebar/Footer/Footer.tsx @@ -7,6 +7,7 @@ import { UpdateNotification } from './UpdateNotifications'; import { BuildInfoModalButton } from './BuildInfoModal'; import '@reach/dialog/styles.css'; import styles from './Footer.module.css'; +import Logo from './portainer_logo.svg?c'; export function Footer() { return isBE ? : ; @@ -18,6 +19,7 @@ function CEFooter() { + Community Edition @@ -41,7 +43,7 @@ function BEFooter() { function FooterContent({ children }: PropsWithChildren) { return ( -
+
{children}
); diff --git a/app/react/sidebar/Footer/portainer_logo.svg b/app/react/sidebar/Footer/portainer_logo.svg new file mode 100644 index 000000000..1bf390370 --- /dev/null +++ b/app/react/sidebar/Footer/portainer_logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/react/sidebar/Header.module.css b/app/react/sidebar/Header.module.css index 82a002e8a..002dad966 100644 --- a/app/react/sidebar/Header.module.css +++ b/app/react/sidebar/Header.module.css @@ -1,5 +1,9 @@ .logo { display: inline; max-height: 55px; - max-width: min(100%, 220px); + max-width: min(100%, 230px); +} + +.collapseBtn:hover { + background-color: var(--bg-btn-default-hover-color); } diff --git a/app/react/sidebar/Header.tsx b/app/react/sidebar/Header.tsx index b69aa7d3a..60b24b883 100644 --- a/app/react/sidebar/Header.tsx +++ b/app/react/sidebar/Header.tsx @@ -2,13 +2,12 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'; import clsx from 'clsx'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import smallLogo from '@/assets/ico/logomark.svg'; import { Link } from '@@/Link'; import fullLogoBE from './portainer_logo-BE.svg'; import fullLogoCE from './portainer_logo-CE.svg'; -import smallLogoBE from './logomark-BE.svg'; -import smallLogoCE from './logomark-CE.svg'; import { useSidebarState } from './useSidebarState'; import styles from './Header.module.css'; @@ -21,7 +20,7 @@ export function Header({ logo: customLogo }: Props) { return (
-
+
); diff --git a/app/react/sidebar/SidebarItem/SidebarItem.tsx b/app/react/sidebar/SidebarItem/SidebarItem.tsx index 7208516f3..96fd5cbc8 100644 --- a/app/react/sidebar/SidebarItem/SidebarItem.tsx +++ b/app/react/sidebar/SidebarItem/SidebarItem.tsx @@ -59,7 +59,7 @@ export function SidebarItem({ return ( +
{title} -
+
{children}
diff --git a/app/react/sidebar/SidebarItem/SidebarTooltip.tsx b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx index ec25ffd99..b9a2d11d4 100644 --- a/app/react/sidebar/SidebarItem/SidebarTooltip.tsx +++ b/app/react/sidebar/SidebarItem/SidebarTooltip.tsx @@ -8,7 +8,7 @@ type Props = { export function SidebarTooltip({ children, content }: Props) { return ( > = {}, options: TransitionOptions = {}, pathOptions: PathOptions = { diff --git a/app/react/sidebar/logomark-BE.svg b/app/react/sidebar/logomark-BE.svg deleted file mode 100644 index beead5989..000000000 --- a/app/react/sidebar/logomark-BE.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/app/react/sidebar/logomark-CE.svg b/app/react/sidebar/logomark-CE.svg deleted file mode 100644 index 1ed2ea259..000000000 --- a/app/react/sidebar/logomark-CE.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/app/react/sidebar/portainer_logo-BE.svg b/app/react/sidebar/portainer_logo-BE.svg index ad9762aa8..ed7ab076d 100644 --- a/app/react/sidebar/portainer_logo-BE.svg +++ b/app/react/sidebar/portainer_logo-BE.svg @@ -1,37 +1,51 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/app/react/sidebar/portainer_logo-CE.svg b/app/react/sidebar/portainer_logo-CE.svg index 707c7b1a7..7b6c83a00 100644 --- a/app/react/sidebar/portainer_logo-CE.svg +++ b/app/react/sidebar/portainer_logo-CE.svg @@ -1,38 +1,68 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + diff --git a/app/setup-tests/mock-codemirror.tsx b/app/setup-tests/mock-codemirror.tsx index 10dcfa11c..5c96025b8 100644 --- a/app/setup-tests/mock-codemirror.tsx +++ b/app/setup-tests/mock-codemirror.tsx @@ -1,36 +1,7 @@ export function mockCodeMirror() { vi.mock('@uiw/react-codemirror', () => ({ __esModule: true, - default: ({ - value, - onChange, - readOnly, - placeholder, - height, - className, - id, - 'data-cy': dataCy, - }: { - value?: string; - onChange?: (value: string) => void; - readOnly?: boolean; - placeholder?: string; - height?: string; - className?: string; - id?: string; - 'data-cy'?: string; - }) => ( -
, oneDarkHighlightStyle: {}, keymap: { of: () => ({}), diff --git a/app/setup-tests/setup-codemirror.ts b/app/setup-tests/setup-codemirror.ts index 6f0514710..e5f83fffc 100644 --- a/app/setup-tests/setup-codemirror.ts +++ b/app/setup-tests/setup-codemirror.ts @@ -1,10 +1,5 @@ import 'vitest-dom/extend-expect'; -import { mockCodeMirror } from './mock-codemirror'; - -// Initialize CodeMirror module mocks -mockCodeMirror(); - // Mock Range APIs that CodeMirror needs but JSDOM doesn't provide Range.prototype.getBoundingClientRect = () => ({ bottom: 0, diff --git a/app/setup-tests/setup-handlers/users.ts b/app/setup-tests/setup-handlers/users.ts index be8c3a5e3..67eb6debb 100644 --- a/app/setup-tests/setup-handlers/users.ts +++ b/app/setup-tests/setup-handlers/users.ts @@ -2,12 +2,9 @@ import { http, HttpResponse } from 'msw'; import { TeamMembership } from '@/react/portainer/users/teams/types'; import { createMockUsers } from '@/react-tools/test-mocks'; -import { Role } from '@/portainer/users/types'; export const userHandlers = [ - http.get('/api/users', async () => - HttpResponse.json(createMockUsers(10, Role.Standard)) - ), + http.get('/api/users', async () => HttpResponse.json(createMockUsers(10))), http.get( '/api/users/:userId/memberships', () => HttpResponse.json([]) diff --git a/binary-version.json b/binary-version.json index 7a275ec82..3f190e5d3 100644 --- a/binary-version.json +++ b/binary-version.json @@ -1,4 +1,5 @@ { - "docker": "v28.3.0", + "docker": "v27.5.1", + "kubectl": "v1.32.2", "mingit": "2.49.0.1" } diff --git a/dev/run_container.sh b/dev/run_container.sh index 56dc1ceda..56b00edd4 100755 --- a/dev/run_container.sh +++ b/dev/run_container.sh @@ -16,7 +16,6 @@ docker run -d \ -v /var/run/docker.sock:/var/run/docker.sock:z \ -v /var/run/docker.sock:/var/run/alternative.sock:z \ -v /tmp:/tmp \ - -e CSP=false \ --name portainer \ portainer/base \ /app/portainer $PORTAINER_FLAGS diff --git a/dev/run_container_podman.sh b/dev/run_container_podman.sh index 03e2fb270..1893efd0b 100755 --- a/dev/run_container_podman.sh +++ b/dev/run_container_podman.sh @@ -16,7 +16,6 @@ sudo podman run -d \ -v "$PORTAINER_DATA:/data" \ -v /run/podman/podman.sock:/var/run/docker.sock \ -v /tmp:/tmp \ - -e CSP=false \ --privileged \ --name portainer \ portainer/base \ diff --git a/go.mod b/go.mod index 2ef7032f2..3a2ba0bf5 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/portainer/portainer -go 1.24.4 +go 1.23.8 require ( github.com/Masterminds/semver v1.5.0 github.com/Microsoft/go-winio v0.6.2 - github.com/RoaringBitmap/roaring/v2 v2.5.0 github.com/VictoriaMetrics/fastcache v1.12.0 github.com/aws/aws-sdk-go-v2 v1.30.3 github.com/aws/aws-sdk-go-v2/credentials v1.17.27 @@ -29,7 +28,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/csrf v1.7.3 github.com/gorilla/mux v1.8.1 - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/gorilla/websocket v1.5.0 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru v0.5.4 github.com/joho/godotenv v1.4.0 @@ -37,7 +36,6 @@ require ( github.com/klauspost/compress v1.18.0 github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c github.com/opencontainers/go-digest v1.0.0 - github.com/opencontainers/image-spec v1.1.1 github.com/orcaman/concurrent-map v1.0.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 @@ -49,23 +47,21 @@ require ( github.com/urfave/negroni v1.0.0 github.com/viney-shih/go-lock v1.1.1 go.etcd.io/bbolt v1.4.0 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.37.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/mod v0.25.0 + golang.org/x/mod v0.24.0 golang.org/x/oauth2 v0.29.0 - golang.org/x/sync v0.15.0 + golang.org/x/sync v0.14.0 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 - helm.sh/helm/v3 v3.18.4 - k8s.io/api v0.33.2 - k8s.io/apimachinery v0.33.2 - k8s.io/cli-runtime v0.33.2 - k8s.io/client-go v0.33.2 - k8s.io/kubectl v0.33.2 - k8s.io/kubelet v0.33.2 - k8s.io/metrics v0.33.2 - oras.land/oras-go/v2 v2.6.0 + helm.sh/helm/v3 v3.17.3 + k8s.io/api v0.32.3 + k8s.io/apimachinery v0.32.3 + k8s.io/cli-runtime v0.32.2 + k8s.io/client-go v0.32.3 + k8s.io/kubectl v0.32.2 + k8s.io/metrics v0.32.2 software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 ) @@ -73,10 +69,11 @@ require github.com/gorilla/securecookie v1.1.2 // indirect require ( dario.cat/mergo v1.0.1 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -84,7 +81,6 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/ProtonMail/go-crypto v1.1.3 // indirect - github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect @@ -103,7 +99,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.12.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -112,7 +107,7 @@ require ( github.com/cloudflare/cfssl v1.6.4 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/containerd/console v1.0.4 // indirect - github.com/containerd/containerd v1.7.27 // indirect + github.com/containerd/containerd v1.7.24 // indirect github.com/containerd/containerd/api v1.9.0 // indirect github.com/containerd/containerd/v2 v2.1.1 // indirect github.com/containerd/continuity v0.4.5 // indirect @@ -125,7 +120,7 @@ require ( github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect github.com/containers/ocicrypt v1.2.1 // indirect github.com/containers/storage v1.53.0 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/cyphar/filepath-securejoin v0.3.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/buildx v0.24.0 // indirect @@ -139,7 +134,7 @@ require ( github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/fatih/color v1.15.0 // indirect @@ -165,8 +160,9 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect @@ -227,9 +223,9 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect @@ -241,7 +237,7 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rubenv/sql-migrate v1.8.0 // indirect + github.com/rubenv/sql-migrate v1.7.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect github.com/segmentio/asm v1.1.3 // indirect @@ -278,8 +274,8 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect @@ -289,10 +285,10 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/mock v0.5.2 // indirect - golang.org/x/net v0.40.0 // indirect + golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect @@ -302,18 +298,17 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - k8s.io/apiextensions-apiserver v0.33.2 // indirect - k8s.io/apiserver v0.33.2 // indirect - k8s.io/component-base v0.33.2 // indirect - k8s.io/component-helpers v0.33.2 // indirect + k8s.io/apiextensions-apiserver v0.32.2 // indirect + k8s.io/apiserver v0.32.3 // indirect + k8s.io/component-base v0.32.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + oras.land/oras-go v1.2.5 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/kustomize/api v0.19.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/kustomize/api v0.18.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/yaml v1.4.0 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index e2eb682a8..b79c1d644 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0= @@ -38,8 +38,6 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/RoaringBitmap/roaring/v2 v2.5.0 h1:TJ45qCM7D7fIEBwKd9zhoR0/S1egfnSSIzLU1e1eYLY= -github.com/RoaringBitmap/roaring/v2 v2.5.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= @@ -102,8 +100,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA= -github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= @@ -140,8 +136,8 @@ github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJ github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= -github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= +github.com/containerd/containerd v1.7.24 h1:zxszGrGjrra1yYJW/6rhm9cJ1ZQ8rkKBR48brqsa7nA= +github.com/containerd/containerd v1.7.24/go.mod h1:7QUzfURqZWCZV7RLNEn1XjUCQLEf0bkaK4GjUaZehxw= github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= github.com/containerd/containerd/v2 v2.1.1 h1:znnkm7Ajz8lg8BcIPMhc/9yjBRN3B+OkNKqKisKfwwM= @@ -180,15 +176,13 @@ github.com/containers/storage v1.53.0/go.mod h1:pujcoOSc+upx15Jirdkebhtd8uJiLwbS github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= +github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -196,10 +190,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= -github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= +github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/buildx v0.24.0 h1:qiD+xktY+Fs3R79oz8M+7pbhip78qGLx6LBuVmyb+64= @@ -241,8 +233,8 @@ github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= @@ -334,13 +326,15 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= +github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.1.4 h1:hCyXHDbtqlr/lMXU0D4WgbalXL0Zk4dSWWMbPV8VrqY= github.com/google/certificate-transparency-go v1.1.4/go.mod h1:D6lvbfwckhNrbM9WVl1EVeMOyzC19mpIjMOI4nxBHtQ= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -356,15 +350,15 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= -github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -384,10 +378,6 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= -github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -552,8 +542,6 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= -github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -627,12 +615,6 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -643,8 +625,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= -github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= -github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= +github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmiUq4= +github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= @@ -750,6 +732,12 @@ github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw= @@ -762,10 +750,6 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= -go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= -go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= -go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0 h1:4BZHA+B1wXEQoGNHxW8mURaLhcdGwvRnmhGbm+odRbc= @@ -774,36 +758,20 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0 h1:ZsXq73BERAiNuuFXYqP4MR5hBrjXfMGSO+Cx7qoOZiM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.31.0/go.mod h1:hg1zaDMpyZJuUzjFxFsRYBoccE86tM9Uf4IqNMUxvrY= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= -go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= -go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= -go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= @@ -825,15 +793,15 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -844,8 +812,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -856,8 +824,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -890,15 +858,15 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -906,8 +874,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -951,55 +919,47 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= -helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= -k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= -k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= -k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= -k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= -k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4= -k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M= -k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= -k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88= -k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= -k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= -k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= -k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= -k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw= -k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU= +helm.sh/helm/v3 v3.17.3 h1:3n5rW3D0ArjFl0p4/oWO8IbY/HKaNNwJtOQFdH2AZHg= +helm.sh/helm/v3 v3.17.3/go.mod h1:+uJKMH/UiMzZQOALR3XUf3BLIoczI2RKKD6bMhPh4G8= +k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= +k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apiserver v0.32.3 h1:kOw2KBuHOA+wetX1MkmrxgBr648ksz653j26ESuWNY8= +k8s.io/apiserver v0.32.3/go.mod h1:q1x9B8E/WzShF49wh3ADOh6muSfpmFL0I2t+TG0Zdgc= +k8s.io/cli-runtime v0.32.2 h1:aKQR4foh9qeyckKRkNXUccP9moxzffyndZAvr+IXMks= +k8s.io/cli-runtime v0.32.2/go.mod h1:a/JpeMztz3xDa7GCyyShcwe55p8pbcCVQxvqZnIwXN8= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/component-base v0.32.3 h1:98WJvvMs3QZ2LYHBzvltFSeJjEx7t5+8s71P7M74u8k= +k8s.io/component-base v0.32.3/go.mod h1:LWi9cR+yPAv7cu2X9rZanTiFKB2kHA+JjmhkKjCZRpI= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= -k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= -k8s.io/kubelet v0.33.2 h1:wxEau5/563oJb3j3KfrCKlNWWx35YlSgDLOYUBCQ0pg= -k8s.io/kubelet v0.33.2/go.mod h1:way8VCDTUMiX1HTOvJv7M3xS/xNysJI6qh7TOqMe5KM= -k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE= -k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/kubectl v0.32.2 h1:TAkag6+XfSBgkqK9I7ZvwtF0WVtUAvK8ZqTt+5zi1Us= +k8s.io/kubectl v0.32.2/go.mod h1:+h/NQFSPxiDZYX/WZaWw9fwYezGLISP0ud8nQKg+3g8= +k8s.io/metrics v0.32.2 h1:7t/rZzTHFrGa9f94XcgLlm3ToAuJtdlHANcJEHlYl9g= +k8s.io/metrics v0.32.2/go.mod h1:VL3nJpzcgB6L5nSljkkzoE0nilZhVgcjCfNRgoylaIQ= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= -oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= +oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= -sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= -sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= -sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= +sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= +sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= +sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4= diff --git a/package.json b/package.json index 8cab91593..d9c180698 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "2.32.0", + "version": "2.31.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" @@ -20,7 +20,7 @@ "dev": "webpack-dev-server", "start": "webpack -w", "build": "webpack", - "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts}\"", + "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts,json}\"", "lint": "eslint --cache --fix './**/*.{js,jsx,ts,tsx}'", "test": "vitest run", "sb": "yarn storybook", @@ -199,7 +199,7 @@ "html-loader": "^0.5.5", "html-webpack-plugin": "^5.5.3", "husky": "^8.0.0", - "kubernetes-types": "^1.30.0", + "kubernetes-types": "^1.26.0", "lint-staged": "^14.0.1", "lodash-webpack-plugin": "^0.11.6", "mini-css-extract-plugin": "^2.7.6", diff --git a/pkg/libhelm/cache/cache.go b/pkg/libhelm/cache/cache.go deleted file mode 100644 index e555bc090..000000000 --- a/pkg/libhelm/cache/cache.go +++ /dev/null @@ -1,126 +0,0 @@ -package cache - -import ( - "fmt" - "time" - - "github.com/patrickmn/go-cache" - portainer "github.com/portainer/portainer/api" - "github.com/rs/zerolog/log" - "helm.sh/helm/v3/pkg/registry" -) - -// Cache manages Helm registry clients with TTL-based expiration -// Registry clients are cached per registry ID rather than per user session -// to optimize rate limiting - one login per registry per Portainer instance -type Cache struct { - cache *cache.Cache -} - -// CachedRegistryClient wraps a registry client with metadata -type CachedRegistryClient struct { - Client *registry.Client - RegistryID portainer.RegistryID - CreatedAt time.Time -} - -// newCache creates a new Helm registry client cache with the specified timeout -func newCache(userSessionTimeout string) (*Cache, error) { - timeout, err := time.ParseDuration(userSessionTimeout) - if err != nil { - return nil, fmt.Errorf("invalid user session timeout: %w", err) - } - - return &Cache{ - cache: cache.New(timeout, timeout), - }, nil -} - -// getByRegistryID retrieves a cached registry client by registry ID -// Cache key strategy: use registryID for maximum efficiency against rate limits -// This means one login per registry per Portainer instance, regardless of user/environment -func (c *Cache) getByRegistryID(registryID portainer.RegistryID) (*registry.Client, bool) { - key := generateRegistryIDCacheKey(registryID) - - cachedClient, found := c.cache.Get(key) - if !found { - log.Debug(). - Str("cache_key", key). - Int("registry_id", int(registryID)). - Str("context", "HelmRegistryCache"). - Msg("Cache miss for registry client") - return nil, false - } - - client := cachedClient.(CachedRegistryClient) - - log.Debug(). - Str("cache_key", key). - Int("registry_id", int(registryID)). - Str("context", "HelmRegistryCache"). - Msg("Cache hit for registry client") - - return client.Client, true -} - -// setByRegistryID stores a registry client in the cache with registry ID context -func (c *Cache) setByRegistryID(registryID portainer.RegistryID, client *registry.Client) { - if client == nil { - log.Warn(). - Int("registry_id", int(registryID)). - Str("context", "HelmRegistryCache"). - Msg("Attempted to cache nil registry client") - return - } - - key := generateRegistryIDCacheKey(registryID) - - cachedClient := CachedRegistryClient{ - Client: client, - RegistryID: registryID, - CreatedAt: time.Now(), - } - - c.cache.Set(key, cachedClient, cache.DefaultExpiration) - - log.Debug(). - Str("cache_key", key). - Int("registry_id", int(registryID)). - Str("context", "HelmRegistryCache"). - Msg("Cached registry client") -} - -// flushRegistry removes cached registry client for a specific registry ID -// This should be called whenever registry credentials change -func (c *Cache) flushRegistry(registryID portainer.RegistryID) { - key := generateRegistryIDCacheKey(registryID) - - c.cache.Delete(key) - log.Info(). - Int("registry_id", int(registryID)). - Str("context", "HelmRegistryCache"). - Msg("Flushed registry client due to registry change") -} - -// flushAll removes all cached registry clients -func (c *Cache) flushAll() { - itemCount := c.cache.ItemCount() - c.cache.Flush() - - if itemCount > 0 { - log.Info(). - Int("cached_clients_removed", itemCount). - Str("context", "HelmRegistryCache"). - Msg("Flushed all registry clients") - } -} - -// generateRegistryIDCacheKey creates a cache key from registry ID -// Key strategy decision: Use registry ID instead of user sessions or URL+username -// This provides optimal rate limiting protection since each registry only gets -// logged into once per Portainer instance, regardless of how many users access it -// RBAC security is enforced before reaching this caching layer -// When a new user needs access, they reuse the same cached client -func generateRegistryIDCacheKey(registryID portainer.RegistryID) string { - return fmt.Sprintf("registry:%d", registryID) -} diff --git a/pkg/libhelm/cache/manager.go b/pkg/libhelm/cache/manager.go deleted file mode 100644 index 6520b314c..000000000 --- a/pkg/libhelm/cache/manager.go +++ /dev/null @@ -1,81 +0,0 @@ -package cache - -import ( - "sync" - - portainer "github.com/portainer/portainer/api" - "github.com/rs/zerolog/log" - "helm.sh/helm/v3/pkg/registry" -) - -var ( - // Global singleton instance - instance *Cache - once sync.Once -) - -// Initialize creates and initializes the global cache instance -func Initialize(userSessionTimeout string) error { - var err error - once.Do(func() { - instance, err = newCache(userSessionTimeout) - if err != nil { - log.Error(). - Err(err). - Str("user_session_timeout", userSessionTimeout). - Msg("Failed to initialize Helm registry cache") - } else { - log.Info(). - Str("user_session_timeout", userSessionTimeout). - Msg("Helm registry cache initialized") - } - }) - return err -} - -// Registry-based cache management functions - -// GetCachedRegistryClientByID retrieves a cached registry client by registry ID -func GetCachedRegistryClientByID(registryID portainer.RegistryID) (*registry.Client, bool) { - if instance == nil { - log.Debug(). - Str("context", "HelmRegistryCache"). - Msg("Cache not initialized, returning nil") - return nil, false - } - return instance.getByRegistryID(registryID) -} - -// SetCachedRegistryClientByID stores a registry client in the cache by registry ID -func SetCachedRegistryClientByID(registryID portainer.RegistryID, client *registry.Client) { - if instance == nil { - log.Warn(). - Str("context", "HelmRegistryCache"). - Msg("Cannot set cache entry - cache not initialized") - return - } - instance.setByRegistryID(registryID, client) -} - -// FlushRegistryByID removes cached registry client for a specific registry ID -// This should be called whenever registry credentials change -func FlushRegistryByID(registryID portainer.RegistryID) { - if instance == nil { - log.Debug(). - Str("context", "HelmRegistryCache"). - Msg("Cache not initialized, nothing to flush") - return - } - instance.flushRegistry(registryID) -} - -// FlushAll removes all cached registry clients -func FlushAll() { - if instance == nil { - log.Debug(). - Str("context", "HelmRegistryCache"). - Msg("Cache not initialized, nothing to flush") - return - } - instance.flushAll() -} diff --git a/pkg/libhelm/options/chart_reference.go b/pkg/libhelm/options/chart_reference.go deleted file mode 100644 index 11b3daf53..000000000 --- a/pkg/libhelm/options/chart_reference.go +++ /dev/null @@ -1,38 +0,0 @@ -package options - -import ( - "strings" -) - -const ( - // OCIProtocolPrefix is the standard OCI protocol prefix - OCIProtocolPrefix = "oci://" -) - -// ConstructChartReference constructs the appropriate chart reference based on registry type -func ConstructChartReference(registryURL string, chartName string) string { - if registryURL == "" { - return chartName - } - - // Don't double-prefix if chart already contains the registry URL - if strings.HasPrefix(chartName, OCIProtocolPrefix) { - return chartName - } - - baseURL := ConstructOCIRegistryReference(registryURL) - - // Handle cases where chartName might already have a path separator - if strings.HasPrefix(chartName, "/") { - return baseURL + chartName - } - - return baseURL + "/" + chartName -} - -func ConstructOCIRegistryReference(registryURL string) string { - // Remove oci:// prefix if present to avoid duplication - registryURL = strings.TrimPrefix(registryURL, OCIProtocolPrefix) - // Ensure we have oci:// prefix for OCI registries - return OCIProtocolPrefix + registryURL -} diff --git a/pkg/libhelm/options/chart_reference_test.go b/pkg/libhelm/options/chart_reference_test.go deleted file mode 100644 index 06db8c26e..000000000 --- a/pkg/libhelm/options/chart_reference_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package options - -import ( - "testing" -) - -func TestConstructChartReference(t *testing.T) { - tests := []struct { - name string - registryURL string - chartName string - expected string - }{ - { - name: "empty registry URL returns chart name as-is", - registryURL: "", - chartName: "nginx", - expected: "nginx", - }, - { - name: "basic OCI registry with chart name", - registryURL: "registry.example.com", - chartName: "nginx", - expected: "oci://registry.example.com/nginx", - }, - { - name: "registry with project path", - registryURL: "harbor.example.com", - chartName: "library/nginx", - expected: "oci://harbor.example.com/library/nginx", - }, - { - name: "chart name already has oci prefix returns as-is", - registryURL: "registry.example.com", - chartName: "oci://registry.example.com/nginx", - expected: "oci://registry.example.com/nginx", - }, - { - name: "chart name with leading slash", - registryURL: "registry.example.com", - chartName: "/nginx", - expected: "oci://registry.example.com/nginx", - }, - { - name: "registry URL already has oci prefix", - registryURL: "oci://registry.example.com", - chartName: "nginx", - expected: "oci://registry.example.com/nginx", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ConstructChartReference(tt.registryURL, tt.chartName) - if result != tt.expected { - t.Errorf("ConstructChartReference(%q, %q) = %q, want %q", - tt.registryURL, tt.chartName, result, tt.expected) - } - }) - } -} - -func TestConstructOCIRegistryReference(t *testing.T) { - tests := []struct { - name string - registryURL string - expected string - }{ - { - name: "simple registry URL", - registryURL: "registry.example.com", - expected: "oci://registry.example.com", - }, - { - name: "registry URL with oci prefix", - registryURL: "oci://registry.example.com", - expected: "oci://registry.example.com", - }, - { - name: "registry URL with port", - registryURL: "registry.example.com:5000", - expected: "oci://registry.example.com:5000", - }, - { - name: "empty registry URL", - registryURL: "", - expected: "oci://", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ConstructOCIRegistryReference(tt.registryURL) - if result != tt.expected { - t.Errorf("ConstructOCIRegistryReference(%q) = %q, want %q", - tt.registryURL, result, tt.expected) - } - }) - } -} diff --git a/pkg/libhelm/options/install_options.go b/pkg/libhelm/options/install_options.go index 7873066b2..807862683 100644 --- a/pkg/libhelm/options/install_options.go +++ b/pkg/libhelm/options/install_options.go @@ -1,10 +1,6 @@ package options -import ( - "time" - - portainer "github.com/portainer/portainer/api" -) +import "time" type InstallOptions struct { Name string @@ -12,12 +8,10 @@ type InstallOptions struct { Version string Namespace string Repo string - Registry *portainer.Registry Wait bool ValuesFile string PostRenderer string Atomic bool - DryRun bool Timeout time.Duration KubernetesClusterAccess *KubernetesClusterAccess diff --git a/pkg/libhelm/options/search_repo_options.go b/pkg/libhelm/options/search_repo_options.go index 333c28c8b..0b35c0bbd 100644 --- a/pkg/libhelm/options/search_repo_options.go +++ b/pkg/libhelm/options/search_repo_options.go @@ -1,15 +1,10 @@ package options -import ( - "net/http" - - portainer "github.com/portainer/portainer/api" -) +import "net/http" type SearchRepoOptions struct { Repo string `example:"https://charts.gitlab.io/"` Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"` Chart string `example:"my-chart"` UseCache bool `example:"false"` - Registry *portainer.Registry } diff --git a/pkg/libhelm/options/show_options.go b/pkg/libhelm/options/show_options.go index dadbab906..a715c6655 100644 --- a/pkg/libhelm/options/show_options.go +++ b/pkg/libhelm/options/show_options.go @@ -1,7 +1,5 @@ package options -import portainer "github.com/portainer/portainer/api" - // ShowOutputFormat is the format of the output of `helm show` type ShowOutputFormat string @@ -22,6 +20,6 @@ type ShowOptions struct { Chart string Repo string Version string - Env []string - Registry *portainer.Registry // Registry credentials for authentication + + Env []string } diff --git a/pkg/libhelm/release/release.go b/pkg/libhelm/release/release.go index bb49c0435..56888291c 100644 --- a/pkg/libhelm/release/release.go +++ b/pkg/libhelm/release/release.go @@ -36,8 +36,6 @@ type Release struct { Manifest string `json:"manifest,omitempty"` // Hooks are all of the hooks declared for this release. Hooks []*Hook `json:"hooks,omitempty"` - // AppVersion is the app version of the release. - AppVersion string `json:"appVersion,omitempty"` // Version is an int which represents the revision of the release. Version int `json:"version,omitempty"` // Namespace is the kubernetes namespace of the release. @@ -45,8 +43,6 @@ type Release struct { // Labels of the release. // Disabled encoding into Json cause labels are stored in storage driver metadata field. Labels map[string]string `json:"-"` - // ChartReference are the labels that are used to identify the chart source. - ChartReference ChartReference `json:"chartReference,omitempty"` // Values are the values used to deploy the chart. Values Values `json:"values,omitempty"` } @@ -56,12 +52,6 @@ type Values struct { ComputedValues string `json:"computedValues,omitempty"` } -type ChartReference struct { - ChartPath string `json:"chartPath,omitempty"` - RepoURL string `json:"repoURL,omitempty"` - RegistryID int64 `json:"registryID,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 { diff --git a/pkg/libhelm/sdk/chartsources.go b/pkg/libhelm/sdk/chartsources.go deleted file mode 100644 index c42196f3d..000000000 --- a/pkg/libhelm/sdk/chartsources.go +++ /dev/null @@ -1,297 +0,0 @@ -package sdk - -// Helm Registry Client Caching Strategy -// -// This package implements a registry-based caching mechanism for Helm OCI registry clients -// to address rate limiting issues caused by repeated registry authentication. -// -// Key Design Decisions: -// -// 1. Cache Key Strategy: Registry ID -// - Uses portainer.RegistryID as the cache key instead of user sessions or URL+username -// - One cached client per registry per Portainer instance, regardless of users -// - Optimal for rate limiting: each registry only gets one login per Portainer instance -// - New users reuse existing cached clients rather than creating new ones -// -// 2. Cache Invalidation: Registry Change Events -// - Cache is flushed when registry credentials are updated (registryUpdate handler) -// - Cache is flushed when registry is reconfigured (registryConfigure handler) -// - Cache is flushed when registry is deleted (registryDelete handler) -// - Cache is flushed when registry authentication fails (show, install, upgrade) -// - No time-based expiration needed since registry credentials rarely change -// -// 3. Alternative Approaches NOT Used: -// - registry.ClientOptCredentialsFile(): Still requires token exchange on each client creation -// - User/session-based caching: Less efficient for rate limiting, creates unnecessary logins -// - URL+username caching: More complex, harder to invalidate, doesn't handle registry updates -// -// 4. Security Model: -// - RBAC security is enforced BEFORE reaching this caching layer (handler.getRegistryWithAccess) - -import ( - "strings" - - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/pkg/libhelm/cache" - "github.com/portainer/portainer/pkg/libhelm/options" - "github.com/rs/zerolog/log" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/registry" - "oras.land/oras-go/v2/registry/remote/retry" -) - -// IsOCIRegistry returns true if the registry is an OCI registry (not nil), false if it's an HTTP repository (nil) -func IsOCIRegistry(registry *portainer.Registry) bool { - return registry != nil -} - -// IsHTTPRepository returns true if it's an HTTP repository (registry is nil), false if it's an OCI registry -func IsHTTPRepository(registry *portainer.Registry) bool { - return registry == nil -} - -// parseChartRef parses chart and repo references based on the registry type -func parseChartRef(chart, repo string, registry *portainer.Registry) (string, string, error) { - if IsHTTPRepository(registry) { - return parseHTTPRepoChartRef(chart, repo) - } - return parseOCIChartRef(chart, registry) -} - -// parseOCIChartRef constructs the full OCI chart reference -func parseOCIChartRef(chart string, registry *portainer.Registry) (string, string, error) { - - chartRef := options.ConstructChartReference(registry.URL, chart) - - log.Debug(). - Str("context", "HelmClient"). - Str("chart_ref", chartRef). - Bool("authentication", registry.Authentication). - Msg("Constructed OCI chart reference") - - return chartRef, registry.URL, nil -} - -// parseHTTPRepoChartRef returns chart and repo as-is for HTTP repositories -func parseHTTPRepoChartRef(chart, repo string) (string, string, error) { - return chart, repo, nil -} - -// shouldFlushCacheOnError determines if a registry client should be removed from cache based on the error -// This helps handle cases where cached credentials have become invalid -func shouldFlushCacheOnError(err error, registryID portainer.RegistryID) bool { - if err == nil || registryID == 0 { - return false - } - - errorStr := strings.ToLower(err.Error()) - - // Authentication/authorization errors that indicate invalid cached credentials - authenticationErrors := []string{ - "unauthorized", - "authentication", - "login failed", - "invalid credentials", - "access denied", - "forbidden", - "401", - "403", - "token", - "auth", - } - - for _, authErr := range authenticationErrors { - if strings.Contains(errorStr, authErr) { - log.Info(). - Int("registry_id", int(registryID)). - Str("error", err.Error()). - Str("context", "HelmClient"). - Msg("Detected authentication error - will flush registry cache") - return true - } - } - - return false -} - -// authenticateChartSource handles both HTTP repositories and OCI registries -func authenticateChartSource(actionConfig *action.Configuration, registry *portainer.Registry) error { - // For HTTP repositories, no authentication needed (CE and EE) - if IsHTTPRepository(registry) { - return nil - } - - // If RegistryClient is already set, we're done - if actionConfig.RegistryClient != nil { - log.Debug(). - Str("context", "HelmClient"). - Msg("Registry client already set in action config") - return nil - } - - // Validate registry credentials first - err := validateRegistryCredentials(registry) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Err(err). - Msg("Registry credential validation failed") - return errors.Wrap(err, "registry credential validation failed") - } - - // No authentication required - if !registry.Authentication { - log.Debug(). - Str("context", "HelmClient"). - Msg("No OCI registry authentication required") - return nil - } - - // Cache Strategy Decision: Use registry ID as cache key - // This provides optimal rate limiting protection since each registry only gets - // logged into once per Portainer instance, regardless of how many users access it. - // RBAC security is enforced before reaching this caching layer. - // When a new user needs access, they reuse the same cached client. - // - // Alternative approach (NOT used): registry.ClientOptCredentialsFile() - // We don't use Helm's credential file approach because: - // 1. It still requires token exchange with registry on each new client creation - // 2. Rate limiting occurs during token exchange, not credential loading - // 3. Our caching approach reuses existing authenticated clients completely - // 4. Credential files add complexity without solving the core rate limiting issue - - // Try to get cached registry client (registry ID-based key) - if cachedClient, found := cache.GetCachedRegistryClientByID(registry.ID); found { - log.Debug(). - Int("registry_id", int(registry.ID)). - Str("registry_url", registry.URL). - Str("context", "HelmClient"). - Msg("Using cached registry client") - - actionConfig.RegistryClient = cachedClient - return nil - } - - // Cache miss - perform login and cache the result - log.Debug(). - Int("registry_id", int(registry.ID)). - Str("registry_url", registry.URL). - Str("context", "HelmClient"). - Msg("Cache miss - creating new registry client") - - registryClient, err := loginToOCIRegistry(registry) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("registry_url", registry.URL). - Err(err). - Msg("Failed to login to registry") - return errors.Wrap(err, "failed to login to registry") - } - - // Cache the client if login was successful (registry ID-based key) - if registryClient != nil { - cache.SetCachedRegistryClientByID(registry.ID, registryClient) - log.Debug(). - Int("registry_id", int(registry.ID)). - Str("registry_url", registry.URL). - Str("context", "HelmClient"). - Msg("Registry client cached successfully") - } - - actionConfig.RegistryClient = registryClient - return nil -} - -// configureChartPathOptions sets chart path options based on registry type -func configureChartPathOptions(chartPathOptions *action.ChartPathOptions, version, repo string, registry *portainer.Registry) error { - chartPathOptions.Version = version - // Set chart path options based on registry type - if IsHTTPRepository(registry) { - configureHTTPRepoChartPathOptions(chartPathOptions, repo) - } else { - configureOCIChartPathOptions(chartPathOptions, registry) - } - - return nil -} - -// configureHTTPRepoChartPathOptions sets chart path options for HTTP repositories -func configureHTTPRepoChartPathOptions(chartPathOptions *action.ChartPathOptions, repo string) { - chartPathOptions.RepoURL = repo -} - -// configureOCIChartPathOptions sets chart path options for OCI registries -func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, registry *portainer.Registry) { - if registry.Authentication { - chartPathOptions.Username = registry.Username - chartPathOptions.Password = registry.Password - } -} - -// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK -// Tries to get a cached registry client if available, otherwise creates and caches a new one -func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client, error) { - if IsHTTPRepository(portainerRegistry) || !portainerRegistry.Authentication { - return nil, nil // No authentication needed - } - - // Check cache first using registry ID-based key - if cachedClient, found := cache.GetCachedRegistryClientByID(portainerRegistry.ID); found { - return cachedClient, nil - } - - log.Debug(). - Str("context", "loginToRegistry"). - Int("registry_id", int(portainerRegistry.ID)). - Str("registry_url", portainerRegistry.URL). - Msg("Attempting to login to OCI registry") - - registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient)) - if err != nil { - return nil, errors.Wrap(err, "failed to create registry client") - } - - loginOpts := []registry.LoginOption{ - registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password), - } - - err = registryClient.Login(portainerRegistry.URL, loginOpts...) - if err != nil { - return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL) - } - - log.Debug(). - Str("context", "loginToRegistry"). - Int("registry_id", int(portainerRegistry.ID)). - Str("registry_url", portainerRegistry.URL). - Msg("Successfully logged in to OCI registry") - - // Cache using registry ID-based key - cache.SetCachedRegistryClientByID(portainerRegistry.ID, registryClient) - - return registryClient, nil -} - -// validateRegistryCredentials validates registry authentication settings -func validateRegistryCredentials(registry *portainer.Registry) error { - if IsHTTPRepository(registry) { - return nil // No registry means no validation needed - } - - if !registry.Authentication { - return nil // No authentication required - } - - // Authentication is enabled - validate credentials - if strings.TrimSpace(registry.Username) == "" { - return errors.New("username is required when registry authentication is enabled") - } - - if strings.TrimSpace(registry.Password) == "" { - return errors.New("password is required when registry authentication is enabled") - } - - return nil -} diff --git a/pkg/libhelm/sdk/chartsources_test.go b/pkg/libhelm/sdk/chartsources_test.go deleted file mode 100644 index 663601abc..000000000 --- a/pkg/libhelm/sdk/chartsources_test.go +++ /dev/null @@ -1,752 +0,0 @@ -package sdk - -import ( - "strings" - "testing" - - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" - helmregistrycache "github.com/portainer/portainer/pkg/libhelm/cache" - "github.com/stretchr/testify/assert" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/registry" -) - -func TestIsOCIRegistry(t *testing.T) { - t.Run("should return false for nil registry (HTTP repo)", func(t *testing.T) { - assert.False(t, IsOCIRegistry(nil)) - }) - - t.Run("should return true for non-nil registry (OCI registry)", func(t *testing.T) { - assert.True(t, IsOCIRegistry(&portainer.Registry{})) - }) -} - -func TestIsHTTPRepository(t *testing.T) { - t.Run("should return true for nil registry (HTTP repo)", func(t *testing.T) { - assert.True(t, IsHTTPRepository(nil)) - }) - - t.Run("should return false for non-nil registry (OCI registry)", func(t *testing.T) { - assert.False(t, IsHTTPRepository(&portainer.Registry{})) - }) -} - -func TestParseHTTPRepoChartRef(t *testing.T) { - is := assert.New(t) - - chartRef, repoURL, err := parseHTTPRepoChartRef("my-chart", "https://my.repo/charts") - - is.NoError(err) - is.Equal("my-chart", chartRef) - is.Equal("https://my.repo/charts", repoURL) -} - -func TestParseOCIChartRef(t *testing.T) { - is := assert.New(t) - - registry := &portainer.Registry{ - URL: "my-registry.io/my-namespace", - Authentication: true, - Username: "user", - Password: "pass", - } - - chartRef, repoURL, err := parseOCIChartRef("my-chart", registry) - - is.NoError(err) - is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef) - is.Equal("my-registry.io/my-namespace", repoURL) -} - -func TestParseOCIChartRef_GitLab(t *testing.T) { - is := assert.New(t) - - registry := &portainer.Registry{ - Type: portainer.GitlabRegistry, - URL: "registry.gitlab.com", - Authentication: true, - Username: "gitlab-ci-token", - Password: "glpat-xxxxxxxxxxxxxxxxxxxx", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - } - - chartRef, repoURL, err := parseOCIChartRef("my-chart", registry) - - is.NoError(err) - is.Equal("oci://registry.gitlab.com/my-chart", chartRef) - is.Equal("registry.gitlab.com", repoURL) -} - -func TestParseChartRef(t *testing.T) { - t.Run("should parse HTTP repo chart ref when registry is nil", func(t *testing.T) { - is := assert.New(t) - - chartRef, repoURL, err := parseChartRef("my-chart", "https://my.repo/charts", nil) - - is.NoError(err) - is.Equal("my-chart", chartRef) - is.Equal("https://my.repo/charts", repoURL) - }) - - t.Run("should parse OCI chart ref when registry is provided", func(t *testing.T) { - is := assert.New(t) - - registry := &portainer.Registry{ - URL: "my-registry.io/my-namespace", - Authentication: true, - Username: "user", - Password: "pass", - } - - chartRef, repoURL, err := parseChartRef("my-chart", "", registry) - - is.NoError(err) - is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef) - is.Equal("my-registry.io/my-namespace", repoURL) - }) -} - -func TestConfigureHTTPRepoChartPathOptions(t *testing.T) { - is := assert.New(t) - chartPathOptions := &action.ChartPathOptions{} - - configureHTTPRepoChartPathOptions(chartPathOptions, "https://my.repo/charts") - - is.Equal("https://my.repo/charts", chartPathOptions.RepoURL) -} - -func TestConfigureOCIChartPathOptions(t *testing.T) { - is := assert.New(t) - chartPathOptions := &action.ChartPathOptions{} - - registry := &portainer.Registry{ - URL: "my-registry.io/my-namespace", - Authentication: true, - Username: "user", - Password: "pass", - } - - configureOCIChartPathOptions(chartPathOptions, registry) - - is.Equal("user", chartPathOptions.Username) - is.Equal("pass", chartPathOptions.Password) -} - -func TestConfigureOCIChartPathOptions_NoAuth(t *testing.T) { - is := assert.New(t) - chartPathOptions := &action.ChartPathOptions{} - - registry := &portainer.Registry{ - URL: "my-registry.io/my-namespace", - Authentication: false, - } - - configureOCIChartPathOptions(chartPathOptions, registry) - - is.Empty(chartPathOptions.Username) - is.Empty(chartPathOptions.Password) -} - -func TestConfigureChartPathOptions(t *testing.T) { - t.Run("should configure HTTP repo when registry is nil", func(t *testing.T) { - is := assert.New(t) - chartPathOptions := &action.ChartPathOptions{} - - err := configureChartPathOptions(chartPathOptions, "1.0.0", "https://my.repo/charts", nil) - - is.NoError(err) - is.Equal("https://my.repo/charts", chartPathOptions.RepoURL) - is.Equal("1.0.0", chartPathOptions.Version) - }) - - t.Run("should configure OCI registry when registry is provided", func(t *testing.T) { - is := assert.New(t) - chartPathOptions := &action.ChartPathOptions{} - - registry := &portainer.Registry{ - URL: "my-registry.io/my-namespace", - Authentication: true, - Username: "user", - Password: "pass", - } - - err := configureChartPathOptions(chartPathOptions, "1.0.0", "", registry) - - is.NoError(err) - is.Equal("user", chartPathOptions.Username) - is.Equal("pass", chartPathOptions.Password) - is.Equal("1.0.0", chartPathOptions.Version) - }) -} - -func TestLoginToOCIRegistry(t *testing.T) { - is := assert.New(t) - - t.Run("should return nil for HTTP repository (nil registry)", func(t *testing.T) { - client, err := loginToOCIRegistry(nil) - is.NoError(err) - is.Nil(client) - }) - - t.Run("should return nil for registry with auth disabled", func(t *testing.T) { - registry := &portainer.Registry{ - URL: "my-registry.io", - Authentication: false, - } - client, err := loginToOCIRegistry(registry) - is.NoError(err) - is.Nil(client) - }) - - t.Run("should return error for invalid credentials", func(t *testing.T) { - registry := &portainer.Registry{ - URL: "my-registry.io", - Authentication: true, - Username: " ", - } - client, err := loginToOCIRegistry(registry) - is.Error(err) - is.Nil(client) - // The error might be a validation error or a login error, both are acceptable - is.True(err.Error() == "username is required when registry authentication is enabled" || - strings.Contains(err.Error(), "failed to login to registry")) - }) - - t.Run("should attempt login for valid credentials", func(t *testing.T) { - registry := &portainer.Registry{ - ID: 123, - URL: "my-registry.io", - Authentication: true, - Username: "user", - Password: "pass", - } - // this will fail because it can't connect to the registry, - // but it proves that the loginToOCIRegistry function is calling the login function. - client, err := loginToOCIRegistry(registry) - is.Error(err) - is.Nil(client) - is.Contains(err.Error(), "failed to login to registry") - }) - - t.Run("should attempt login for GitLab registry with valid credentials", func(t *testing.T) { - registry := &portainer.Registry{ - ID: 456, - Type: portainer.GitlabRegistry, - URL: "registry.gitlab.com", - Authentication: true, - Username: "gitlab-ci-token", - Password: "glpat-xxxxxxxxxxxxxxxxxxxx", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - } - // this will fail because it can't connect to the registry, - // but it proves that the loginToOCIRegistry function is calling the login function. - client, err := loginToOCIRegistry(registry) - is.Error(err) - is.Nil(client) - is.Contains(err.Error(), "failed to login to registry") - }) -} - -func TestAuthenticateChartSource(t *testing.T) { - t.Run("should do nothing for HTTP repo (nil registry)", func(t *testing.T) { - is := assert.New(t) - actionConfig := &action.Configuration{} - err := authenticateChartSource(actionConfig, nil) - is.NoError(err) - is.Nil(actionConfig.RegistryClient) - }) - - t.Run("should do nothing if registry client already set", func(t *testing.T) { - is := assert.New(t) - actionConfig := &action.Configuration{} - // Mock an existing registry client - existingClient := ®istry.Client{} - actionConfig.RegistryClient = existingClient - - registry := &portainer.Registry{ - ID: 123, - Authentication: true, - Username: "user", - Password: "pass", - } - - err := authenticateChartSource(actionConfig, registry) - is.NoError(err) - is.Equal(existingClient, actionConfig.RegistryClient) - }) - - t.Run("should authenticate OCI registry when registry is provided", func(t *testing.T) { - is := assert.New(t) - actionConfig := &action.Configuration{} - registry := &portainer.Registry{ - ID: 123, - Authentication: false, - } - err := authenticateChartSource(actionConfig, registry) - is.NoError(err) - }) - - t.Run("should return error for invalid registry credentials", func(t *testing.T) { - is := assert.New(t) - actionConfig := &action.Configuration{} - registry := &portainer.Registry{ - ID: 123, - Authentication: true, - Username: " ", // Invalid username - } - err := authenticateChartSource(actionConfig, registry) - is.Error(err) - is.Contains(err.Error(), "registry credential validation failed") - }) -} - -func TestGetRegistryClientFromCache(t *testing.T) { - // Initialize cache for testing - err := helmregistrycache.Initialize("24h") - if err != nil { - t.Fatalf("Failed to initialize cache: %v", err) - } - // Clear cache before each test - helmregistrycache.FlushAll() - - t.Run("should return nil for invalid registry ID", func(t *testing.T) { - is := assert.New(t) - client, found := helmregistrycache.GetCachedRegistryClientByID(0) - is.False(found) - is.Nil(client) - }) - - t.Run("should return nil for non-existent registry ID", func(t *testing.T) { - is := assert.New(t) - client, found := helmregistrycache.GetCachedRegistryClientByID(123) - is.False(found) - is.Nil(client) - }) - - t.Run("should return cached client for valid registry ID", func(t *testing.T) { - is := assert.New(t) - // Create a mock client - mockClient := ®istry.Client{} - - // Store in cache - helmregistrycache.SetCachedRegistryClientByID(123, mockClient) - - // Retrieve from cache - cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123) - is.True(found) - is.NotNil(cachedClient) - is.Equal(mockClient, cachedClient) - }) -} - -func TestSetRegistryClientInCache(t *testing.T) { - // Initialize cache for testing - err := helmregistrycache.Initialize("24h") - if err != nil { - t.Fatalf("Failed to initialize cache: %v", err) - } - // Clear cache before each test - helmregistrycache.FlushAll() - - t.Run("should store and retrieve client successfully", func(t *testing.T) { - is := assert.New(t) - // Create a mock client - client := ®istry.Client{} - - // Store in cache - helmregistrycache.SetCachedRegistryClientByID(123, client) - - // Verify the cache returns the client - cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123) - is.True(found) - is.NotNil(cachedClient) - is.Equal(client, cachedClient) - }) - - t.Run("should handle invalid parameters gracefully", func(t *testing.T) { - // Clear cache to start clean - helmregistrycache.FlushAll() - - // These should not panic - helmregistrycache.SetCachedRegistryClientByID(0, nil) // nil client should be rejected - helmregistrycache.SetCachedRegistryClientByID(999, ®istry.Client{}) // valid client with registry ID 999 should be accepted - helmregistrycache.SetCachedRegistryClientByID(123, nil) // nil client should be rejected - - // Verify that nil clients don't get stored, but valid clients do - is := assert.New(t) - - // Registry ID 999 with a valid client should be found (the second call above) - client, found := helmregistrycache.GetCachedRegistryClientByID(999) - is.True(found) - is.NotNil(client) - - // Registry ID 0 with nil client should not be found - client, found = helmregistrycache.GetCachedRegistryClientByID(0) - is.False(found) - is.Nil(client) - - // Registry ID 123 with nil client should not be found - client, found = helmregistrycache.GetCachedRegistryClientByID(123) - is.False(found) - is.Nil(client) - }) -} - -func TestFlushRegistryCache(t *testing.T) { - // Initialize cache for testing - err := helmregistrycache.Initialize("24h") - if err != nil { - t.Fatalf("Failed to initialize cache: %v", err) - } - // Clear cache before test - helmregistrycache.FlushAll() - - t.Run("should flush specific registry cache", func(t *testing.T) { - is := assert.New(t) - // Create mock clients - client1 := ®istry.Client{} - client2 := ®istry.Client{} - - // Store in cache - helmregistrycache.SetCachedRegistryClientByID(123, client1) - helmregistrycache.SetCachedRegistryClientByID(456, client2) - - // Verify both are cached - client, found := helmregistrycache.GetCachedRegistryClientByID(123) - is.True(found) - is.NotNil(client) - client, found = helmregistrycache.GetCachedRegistryClientByID(456) - is.True(found) - is.NotNil(client) - - // Flush only one - helmregistrycache.FlushRegistryByID(123) - - // Verify only one is flushed - client, found = helmregistrycache.GetCachedRegistryClientByID(123) - is.False(found) - is.Nil(client) - client, found = helmregistrycache.GetCachedRegistryClientByID(456) - is.True(found) - is.NotNil(client) - }) -} - -func TestFlushAllRegistryCache(t *testing.T) { - // Initialize cache for testing - err := helmregistrycache.Initialize("24h") - if err != nil { - t.Fatalf("Failed to initialize cache: %v", err) - } - - t.Run("should flush all registry cache", func(t *testing.T) { - is := assert.New(t) - // Create mock clients - client1 := ®istry.Client{} - client2 := ®istry.Client{} - - // Store in cache - helmregistrycache.SetCachedRegistryClientByID(123, client1) - helmregistrycache.SetCachedRegistryClientByID(456, client2) - - // Verify both are cached - client, found := helmregistrycache.GetCachedRegistryClientByID(123) - is.True(found) - is.NotNil(client) - client, found = helmregistrycache.GetCachedRegistryClientByID(456) - is.True(found) - is.NotNil(client) - - // Flush all - helmregistrycache.FlushAll() - - // Verify both are flushed - client, found = helmregistrycache.GetCachedRegistryClientByID(123) - is.False(found) - is.Nil(client) - client, found = helmregistrycache.GetCachedRegistryClientByID(456) - is.False(found) - is.Nil(client) - client, found = helmregistrycache.GetCachedRegistryClientByID(456) - is.False(found) - is.Nil(client) - }) -} - -func TestValidateRegistryCredentials(t *testing.T) { - tests := []struct { - name string - registry *portainer.Registry - expectError bool - errorMsg string - }{ - { - name: "nil registry should pass validation", - registry: nil, - expectError: false, - }, - { - name: "registry with authentication disabled should pass validation", - registry: &portainer.Registry{ - Authentication: false, - }, - expectError: false, - }, - { - name: "registry with authentication enabled and valid credentials should pass", - registry: &portainer.Registry{ - Authentication: true, - Username: "testuser", - Password: "testpass", - }, - expectError: false, - }, - { - name: "registry with authentication enabled but empty username should fail", - registry: &portainer.Registry{ - Authentication: true, - Username: "", - Password: "testpass", - }, - expectError: true, - errorMsg: "username is required when registry authentication is enabled", - }, - { - name: "registry with authentication enabled but whitespace username should fail", - registry: &portainer.Registry{ - Authentication: true, - Username: " ", - Password: "testpass", - }, - expectError: true, - errorMsg: "username is required when registry authentication is enabled", - }, - { - name: "registry with authentication enabled but empty password should fail", - registry: &portainer.Registry{ - Authentication: true, - Username: "testuser", - Password: "", - }, - expectError: true, - errorMsg: "password is required when registry authentication is enabled", - }, - { - name: "registry with authentication enabled but whitespace password should fail", - registry: &portainer.Registry{ - Authentication: true, - Username: "testuser", - Password: " ", - }, - expectError: true, - errorMsg: "password is required when registry authentication is enabled", - }, - { - name: "GitLab registry with authentication enabled and valid credentials should pass", - registry: &portainer.Registry{ - Type: portainer.GitlabRegistry, - Authentication: true, - Username: "gitlab-ci-token", - Password: "glpat-xxxxxxxxxxxxxxxxxxxx", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - }, - expectError: false, - }, - { - name: "GitLab registry with authentication enabled but empty username should fail", - registry: &portainer.Registry{ - Type: portainer.GitlabRegistry, - Authentication: true, - Username: "", - Password: "glpat-xxxxxxxxxxxxxxxxxxxx", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - }, - expectError: true, - errorMsg: "username is required when registry authentication is enabled", - }, - { - name: "GitLab registry with authentication enabled but empty password should fail", - registry: &portainer.Registry{ - Type: portainer.GitlabRegistry, - Authentication: true, - Username: "gitlab-ci-token", - Password: "", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - }, - expectError: true, - errorMsg: "password is required when registry authentication is enabled", - }, - { - name: "GitLab registry with authentication disabled should pass validation", - registry: &portainer.Registry{ - Type: portainer.GitlabRegistry, - Authentication: false, - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateRegistryCredentials(tt.registry) - - if tt.expectError { - assert.Error(t, err) - if err != nil { - assert.Equal(t, tt.errorMsg, err.Error()) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -// Note: buildCacheKey function was removed since we now use registry ID-based caching -// instead of endpoint/session-based caching for better rate limiting protection - -func TestShouldFlushCacheOnError(t *testing.T) { - tests := []struct { - name string - err error - registryID portainer.RegistryID - shouldFlush bool - }{ - { - name: "nil error should not flush", - err: nil, - registryID: 123, - shouldFlush: false, - }, - { - name: "zero registry ID should not flush", - err: errors.New("some error"), - registryID: 0, - shouldFlush: false, - }, - { - name: "unauthorized error should flush", - err: errors.New("unauthorized access to registry"), - registryID: 123, - shouldFlush: true, - }, - { - name: "authentication failed error should flush", - err: errors.New("authentication failed"), - registryID: 123, - shouldFlush: true, - }, - { - name: "login failed error should flush", - err: errors.New("login failed for user"), - registryID: 123, - shouldFlush: true, - }, - { - name: "invalid credentials error should flush", - err: errors.New("invalid credentials provided"), - registryID: 123, - shouldFlush: true, - }, - { - name: "access denied error should flush", - err: errors.New("access denied to repository"), - registryID: 123, - shouldFlush: true, - }, - { - name: "forbidden error should flush", - err: errors.New("forbidden: insufficient permissions"), - registryID: 123, - shouldFlush: true, - }, - { - name: "401 error should flush", - err: errors.New("HTTP 401 Unauthorized"), - registryID: 123, - shouldFlush: true, - }, - { - name: "403 error should flush", - err: errors.New("HTTP 403 Forbidden"), - registryID: 123, - shouldFlush: true, - }, - { - name: "token error should flush", - err: errors.New("token expired or invalid"), - registryID: 123, - shouldFlush: true, - }, - { - name: "auth error should flush", - err: errors.New("auth validation failed"), - registryID: 123, - shouldFlush: true, - }, - { - name: "chart not found error should not flush", - err: errors.New("chart not found in repository"), - registryID: 123, - shouldFlush: false, - }, - { - name: "network error should not flush", - err: errors.New("connection timeout"), - registryID: 123, - shouldFlush: false, - }, - { - name: "helm validation error should not flush", - err: errors.New("invalid chart values"), - registryID: 123, - shouldFlush: false, - }, - { - name: "kubernetes error should not flush", - err: errors.New("namespace not found"), - registryID: 123, - shouldFlush: false, - }, - { - name: "case insensitive matching works", - err: errors.New("UNAUTHORIZED access denied"), - registryID: 123, - shouldFlush: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := shouldFlushCacheOnError(tt.err, tt.registryID) - is := assert.New(t) - is.Equal(tt.shouldFlush, result, "Expected shouldFlushCacheOnError to return %v for error: %v", tt.shouldFlush, tt.err) - }) - } -} diff --git a/pkg/libhelm/sdk/common.go b/pkg/libhelm/sdk/common.go index c831a69fa..2a51c7f8b 100644 --- a/pkg/libhelm/sdk/common.go +++ b/pkg/libhelm/sdk/common.go @@ -1,38 +1,24 @@ package sdk import ( - "fmt" - "maps" - "net/url" "os" - "path/filepath" - "strconv" - "strings" "github.com/pkg/errors" - "github.com/portainer/portainer/pkg/libhelm/options" - "github.com/portainer/portainer/pkg/libhelm/release" "github.com/rs/zerolog/log" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/downloader" "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/repo" -) - -// Helm chart reference label constants -const ( - ChartPathAnnotation = "portainer/chart-path" - RepoURLAnnotation = "portainer/repo-url" - RegistryIDAnnotation = "portainer/registry-id" ) // loadAndValidateChartWithPathOptions locates and loads the chart, and validates it. // it also checks for chart dependencies and updates them if necessary. // it returns the chart information. func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, version string, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) { + // Locate and load the chart + chartPathOptions.RepoURL = repoURL + chartPathOptions.Version = version chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings) if err != nil { log.Error(). @@ -40,11 +26,6 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath Str("chart", chartName). Err(err). Msg("Failed to locate chart for helm " + operation) - - // For OCI charts, chartName already contains the full reference - if strings.HasPrefix(chartName, options.OCIProtocolPrefix) { - return nil, errors.Wrapf(err, "failed to find the helm chart: %s", chartName) - } return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", repoURL, chartName) } @@ -105,186 +86,3 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath return chartReq, nil } - -// parseRepoURL parses and validates a Helm repository URL using RFC 3986 standards. -// Used by search and show operations before downloading index.yaml files. -func parseRepoURL(repoURL string) (*url.URL, error) { - parsedURL, err := url.ParseRequestURI(repoURL) - if err != nil { - return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL) - } - return parsedURL, nil -} - -// getRepoNameFromURL generates a unique repository identifier from a URL. -// Combines hostname and path for uniqueness (e.g., "charts.helm.sh/stable" → "charts.helm.sh-stable"). -// Used for Helm's repositories.yaml entries, caching, and chart references. -func getRepoNameFromURL(urlStr string) (string, error) { - parsedURL, err := url.Parse(urlStr) - if err != nil { - return "", fmt.Errorf("failed to parse URL: %w", err) - } - - hostname := parsedURL.Hostname() - path := parsedURL.Path - path = strings.Trim(path, "/") - path = strings.ReplaceAll(path, "/", "-") - - if path == "" { - return hostname, nil - } - return fmt.Sprintf("%s-%s", hostname, path), nil -} - -// loadIndexFile loads and parses a Helm repository index.yaml file. -// Called after downloading from HTTP repos or generating from OCI registries. -// Contains chart metadata used for discovery, version resolution, and caching. -func loadIndexFile(indexPath string) (*repo.IndexFile, error) { - log.Debug(). - Str("context", "HelmClient"). - Str("index_path", indexPath). - Msg("Loading index file") - - indexFile, err := repo.LoadIndexFile(indexPath) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("index_path", indexPath). - Err(err). - Msg("Failed to load index file") - return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath) - } - return indexFile, nil -} - -// ensureHelmDirectoriesExist creates required Helm directories and configuration files. -// Creates repository cache, config directories, and ensures repositories.yaml exists. -// Essential for Helm operations to function properly. -func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error { - log.Debug(). - Str("context", "helm_sdk_dirs"). - Msg("Ensuring Helm directories exist") - - // List of directories to ensure exist - directories := []string{ - filepath.Dir(settings.RepositoryConfig), // Repository config directory - settings.RepositoryCache, // Repository cache directory - filepath.Dir(settings.RegistryConfig), // Registry config directory - settings.PluginsDirectory, // Plugins directory - } - - // Create each directory if it doesn't exist - for _, dir := range directories { - if dir == "" { - continue // Skip empty paths - } - - if _, err := os.Stat(dir); os.IsNotExist(err) { - if err := os.MkdirAll(dir, 0700); err != nil { - log.Error(). - Str("context", "helm_sdk_dirs"). - Str("directory", dir). - Err(err). - Msg("Failed to create directory") - return errors.Wrapf(err, "failed to create directory: %s", dir) - } - } - } - - // Ensure registry config file exists - if settings.RegistryConfig != "" { - if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) { - // Create the directory if it doesn't exist - dir := filepath.Dir(settings.RegistryConfig) - if err := os.MkdirAll(dir, 0700); err != nil { - log.Error(). - Str("context", "helm_sdk_dirs"). - Str("directory", dir). - Err(err). - Msg("Failed to create directory") - return errors.Wrapf(err, "failed to create directory: %s", dir) - } - - // Create an empty registry config file - if _, err := os.Create(settings.RegistryConfig); err != nil { - log.Error(). - Str("context", "helm_sdk_dirs"). - Str("file", settings.RegistryConfig). - Err(err). - Msg("Failed to create registry config file") - return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig) - } - } - } - - // Ensure repository config file exists - if settings.RepositoryConfig != "" { - if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) { - // Create an empty repository config file with default yaml structure - f := repo.NewFile() - if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil { - log.Error(). - Str("context", "helm_sdk_dirs"). - Str("file", settings.RepositoryConfig). - Err(err). - Msg("Failed to create repository config file") - return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig) - } - } - } - - log.Debug(). - Str("context", "helm_sdk_dirs"). - Msg("Successfully ensured all Helm directories exist") - - return nil -} - -// 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, existingAnnotations map[string]string) map[string]string { - // Copy existing annotations - annotations := make(map[string]string) - maps.Copy(annotations, existingAnnotations) - - // delete the existing portainer specific labels, for a clean overwrite - delete(annotations, ChartPathAnnotation) - delete(annotations, RepoURLAnnotation) - delete(annotations, RegistryIDAnnotation) - - if chartPath != "" { - annotations[ChartPathAnnotation] = chartPath - } - - if repoURL != "" && registryID == 0 { - annotations[RepoURLAnnotation] = repoURL - } - - if registryID != 0 { - annotations[RegistryIDAnnotation] = strconv.Itoa(registryID) - } - - return annotations -} - -// extractChartReferenceAnnotations decodes chart reference labels for display purposes. -// It handles existing labels gracefully and only decodes known chart reference labels. -// If a chart reference label cannot be decoded, it is omitted entirely from the result. -// Returns a ChartReference struct with decoded values. -func extractChartReferenceAnnotations(annotations map[string]string) release.ChartReference { - if annotations == nil { - return release.ChartReference{} - } - - registryID, err := strconv.Atoi(annotations[RegistryIDAnnotation]) - if err != nil { - registryID = 0 - } - - return release.ChartReference{ - ChartPath: annotations[ChartPathAnnotation], - RepoURL: annotations[RepoURLAnnotation], - RegistryID: int64(registryID), - } -} diff --git a/pkg/libhelm/sdk/get.go b/pkg/libhelm/sdk/get.go index e252ce7a6..c15b65715 100644 --- a/pkg/libhelm/sdk/get.go +++ b/pkg/libhelm/sdk/get.go @@ -97,7 +97,6 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel AppVersion: sdkRelease.Chart.Metadata.AppVersion, }, }, - Values: values, - ChartReference: extractChartReferenceAnnotations(sdkRelease.Chart.Metadata.Annotations), + Values: values, } } diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go index 4b172efaa..ca904dcca 100644 --- a/pkg/libhelm/sdk/install.go +++ b/pkg/libhelm/sdk/install.go @@ -4,7 +4,6 @@ import ( "time" "github.com/pkg/errors" - "github.com/portainer/portainer/pkg/libhelm/cache" "github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/release" "github.com/rs/zerolog/log" @@ -43,12 +42,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation") } - // Setup chart source - err = authenticateChartSource(actionConfig, installOpts.Registry) - if err != nil { - return nil, errors.Wrap(err, "failed to setup chart source for helm release installation") - } - installClient, err := initInstallClient(actionConfig, installOpts) if err != nil { log.Error(). @@ -58,7 +51,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation") } - values, err := hspm.getHelmValuesFromFile(installOpts.ValuesFile) + values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile) if err != nil { log.Error(). Str("context", "HelmClient"). @@ -67,36 +60,15 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( 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) - if err != nil { - return nil, errors.Wrap(err, "failed to parse chart reference for helm release installation") - } - chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, chartRef, installOpts.Version, repoURL, installClient.DependencyUpdate, "release installation") + chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Version, installOpts.Repo, installClient.DependencyUpdate, "release installation") if err != nil { log.Error(). Str("context", "HelmClient"). Err(err). Msg("Failed to load and validate chart for helm release installation") - - // Check if this is an authentication error and flush cache if needed - if installOpts.Registry != nil && shouldFlushCacheOnError(err, installOpts.Registry.ID) { - cache.FlushRegistryByID(installOpts.Registry.ID) - log.Info(). - Int("registry_id", int(installOpts.Registry.ID)). - Str("context", "HelmClient"). - Msg("Flushed registry cache due to chart loading authentication error during install") - } - return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation") } - // Add chart references to annotations - var registryID int - if installOpts.Registry != nil { - registryID = int(installOpts.Registry.ID) - } - chart.Metadata.Annotations = appendChartReferenceAnnotations(installOpts.Chart, installOpts.Repo, registryID, chart.Metadata.Annotations) - // Run the installation log.Info(). Str("context", "HelmClient"). @@ -104,6 +76,7 @@ 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(). @@ -113,10 +86,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( Str("namespace", installOpts.Namespace). Err(err). Msg("Failed to install helm chart for helm release installation") - if installOpts.DryRun { - // remove installation wording for dry run. The inner error has enough context. - return nil, errors.Wrap(err, "dry-run failed") - } return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation") } @@ -125,10 +94,9 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) ( Namespace: helmRelease.Namespace, Chart: release.Chart{ Metadata: &release.Metadata{ - Name: helmRelease.Chart.Metadata.Name, - Version: helmRelease.Chart.Metadata.Version, - AppVersion: helmRelease.Chart.Metadata.AppVersion, - Annotations: helmRelease.Chart.Metadata.Annotations, + Name: helmRelease.Chart.Metadata.Name, + Version: helmRelease.Chart.Metadata.Version, + AppVersion: helmRelease.Chart.Metadata.AppVersion, }, }, Labels: helmRelease.Labels, @@ -143,18 +111,13 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I installClient := action.NewInstall(actionConfig) installClient.DependencyUpdate = true installClient.ReleaseName = installOpts.Name + installClient.ChartPathOptions.RepoURL = installOpts.Repo installClient.Wait = installOpts.Wait installClient.Timeout = installOpts.Timeout - installClient.Version = installOpts.Version - installClient.DryRun = installOpts.DryRun - err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry) - if err != nil { - return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation") - } // Set default values if not specified if installOpts.Timeout == 0 { - installClient.Timeout = 15 * time.Minute // set a bigger timeout for large charts + installClient.Timeout = 5 * time.Minute } else { installClient.Timeout = installOpts.Timeout } diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go index 42011d4ae..63015330c 100644 --- a/pkg/libhelm/sdk/search_repo.go +++ b/pkg/libhelm/sdk/search_repo.go @@ -1,30 +1,24 @@ package sdk import ( - "context" - "fmt" - "io" + "net/url" + "os" "path/filepath" - "strings" "sync" "time" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/pkg/libhelm/options" - "github.com/portainer/portainer/pkg/liboras" "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" - "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/repo" - "oras.land/oras-go/v2/registry" ) var ( errRequiredSearchOptions = errors.New("repo is required") + errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid") ) type RepoIndex struct { @@ -46,6 +40,7 @@ var ( // SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller. func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) { + // Validate input options if err := validateSearchRepoOptions(searchRepoOpts); err != nil { log.Error(). Str("context", "HelmClient"). @@ -60,8 +55,33 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO Str("repo", searchRepoOpts.Repo). Msg("Searching repository") + // Parse and validate the repository URL + repoURL, err := parseRepoURL(searchRepoOpts.Repo) + if err != nil { + log.Error(). + Str("context", "HelmClient"). + Str("repo", searchRepoOpts.Repo). + Err(err). + Msg("Invalid repository URL") + return nil, err + } + + // Check cache first + if searchRepoOpts.UseCache { + cacheMutex.RLock() + if cached, exists := indexCache[repoURL.String()]; exists { + if time.Since(cached.Timestamp) < cacheDuration { + cacheMutex.RUnlock() + return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart) + } + } + cacheMutex.RUnlock() + } + // Set up Helm CLI environment repoSettings := cli.New() + + // Ensure all required Helm directories exist if err := ensureHelmDirectoriesExist(repoSettings); err != nil { log.Error(). Str("context", "HelmClient"). @@ -70,145 +90,70 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO return nil, errors.Wrap(err, "failed to ensure Helm directories exist") } - // Try cache first for HTTP repos - if IsHTTPRepository(searchRepoOpts.Registry) && searchRepoOpts.UseCache { - if cachedResult := hspm.tryGetFromCache(searchRepoOpts.Repo, searchRepoOpts.Chart); cachedResult != nil { - return cachedResult, nil - } - } - - // Download index based on source type - indexFile, err := hspm.downloadRepoIndex(searchRepoOpts, repoSettings) + // Download the index file and update repository configuration + indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo) if err != nil { + log.Error(). + Str("context", "HelmClient"). + Str("repo_url", repoURL.String()). + Err(err). + Msg("Failed to download repository index") return nil, err } - // Update cache for HTTP repos - if IsHTTPRepository(searchRepoOpts.Registry) { - hspm.updateCache(searchRepoOpts.Repo, indexFile) + // Load and parse the index file + log.Debug(). + Str("context", "HelmClient"). + Str("index_path", indexPath). + Msg("Loading index file") + + indexFile, err := loadIndexFile(indexPath) + if err != nil { + log.Error(). + Str("context", "HelmClient"). + Str("index_path", indexPath). + Err(err). + Msg("Failed to load index file") + return nil, err } - return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart) -} - -// tryGetFromCache attempts to retrieve a cached index file and convert it to the response format -func (hspm *HelmSDKPackageManager) tryGetFromCache(repoURL, chartName string) []byte { - cacheMutex.RLock() - defer cacheMutex.RUnlock() - - if cached, exists := indexCache[repoURL]; exists { - if time.Since(cached.Timestamp) < cacheDuration { - result, err := convertAndMarshalIndex(cached.Index, chartName) - if err != nil { - log.Debug(). - Str("context", "HelmClient"). - Str("repo", repoURL). - Err(err). - Msg("Failed to convert cached index") - return nil - } - return result - } - } - return nil -} - -// updateCache updates the cache with the provided index file and cleans up expired entries -func (hspm *HelmSDKPackageManager) updateCache(repoURL string, indexFile *repo.IndexFile) { + // Update cache and remove old entries cacheMutex.Lock() - defer cacheMutex.Unlock() - - indexCache[repoURL] = RepoIndexCache{ + indexCache[searchRepoOpts.Repo] = RepoIndexCache{ Index: indexFile, Timestamp: time.Now(), } - - // Clean up expired entries for key, index := range indexCache { if time.Since(index.Timestamp) > cacheDuration { delete(indexCache, key) } } -} -// downloadRepoIndex downloads the repository index based on the source type (HTTP or OCI) -func (hspm *HelmSDKPackageManager) downloadRepoIndex(opts options.SearchRepoOptions, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) { - if IsOCIRegistry(opts.Registry) { - return hspm.downloadOCIRepoIndex(opts.Registry, repoSettings, opts.Chart) - } - return hspm.downloadHTTPRepoIndex(opts.Repo, repoSettings) -} + cacheMutex.Unlock() -// downloadHTTPRepoIndex downloads and loads an index file from an HTTP repository -func (hspm *HelmSDKPackageManager) downloadHTTPRepoIndex(repoURL string, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) { - parsedURL, err := parseRepoURL(repoURL) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("repo", repoURL). - Err(err). - Msg("Invalid repository URL") - return nil, err - } - - repoName, err := getRepoNameFromURL(parsedURL.String()) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Err(err). - Msg("Failed to get hostname from URL") - return nil, err - } - - indexPath, err := downloadRepoIndexFromHttpRepo(parsedURL.String(), repoSettings, repoName) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("repo_url", parsedURL.String()). - Err(err). - Msg("Failed to download repository index") - return nil, err - } - - return loadIndexFile(indexPath) -} - -// downloadOCIRepoIndex downloads and loads an index file from an OCI registry -func (hspm *HelmSDKPackageManager) downloadOCIRepoIndex(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (*repo.IndexFile, error) { - // Validate registry credentials first - if err := validateRegistryCredentials(registry); err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("repo", registry.URL). - Err(err). - Msg("Registry credential validation failed for OCI search") - return nil, fmt.Errorf("registry credential validation failed: %w", err) - } - - indexPath, err := downloadRepoIndexFromOciRegistry(registry, repoSettings, chartPath) - if err != nil { - log.Error(). - Str("context", "HelmClient"). - Str("repo", registry.URL). - Err(err). - Msg("Failed to download repository index") - return nil, err - } - - return loadIndexFile(indexPath) + return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart) } // validateSearchRepoOptions validates the required search repository options. func validateSearchRepoOptions(opts options.SearchRepoOptions) error { - if opts.Repo == "" && IsHTTPRepository(opts.Registry) { + if opts.Repo == "" { return errRequiredSearchOptions } return nil } -// downloadRepoIndexFromHttpRepo downloads the index.yaml file from the repository and updates +// parseRepoURL parses and validates the repository URL. +func parseRepoURL(repoURL string) (*url.URL, error) { + parsedURL, err := url.ParseRequestURI(repoURL) + if err != nil { + return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL) + } + return parsedURL, nil +} + +// downloadRepoIndex downloads the index.yaml file from the repository and updates // the repository configuration. -func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) { +func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) { log.Debug(). Str("context", "helm_sdk_repo_index"). Str("repo_url", repoURLString). @@ -218,8 +163,7 @@ func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSe // Create chart repository object rep, err := repo.NewChartRepository( &repo.Entry{ - Name: repoName, - URL: repoURLString, + URL: repoURLString, }, getter.All(repoSettings), ) @@ -229,7 +173,7 @@ func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSe Str("repo_url", repoURLString). Err(err). Msg("Failed to create chart repository object") - return "", errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid") + return "", errInvalidRepoURL } // Load repository configuration file @@ -285,168 +229,13 @@ func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSe return indexPath, nil } -func downloadRepoIndexFromOciRegistry(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (string, error) { - if IsHTTPRepository(registry) { - return "", errors.New("registry information is required for OCI search") - } - - if chartPath == "" { - return "", errors.New("chart path is required for OCI search") - } - - ctx := context.Background() - - registryClient, err := liboras.CreateClient(*registry) +// loadIndexFile loads the index file from the given path. +func loadIndexFile(indexPath string) (*repo.IndexFile, error) { + indexFile, err := repo.LoadIndexFile(indexPath) if err != nil { - log.Error(). - Str("context", "helm_sdk_repo_index_oci"). - Str("registry_url", registry.URL). - Err(err). - Msg("Failed to create ORAS registry client") - return "", errors.Wrap(err, "failed to create ORAS registry client") + return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath) } - - // Obtain repository handle for the specific chart path (relative to registry host) - repository, err := registryClient.Repository(ctx, chartPath) - if err != nil { - log.Error(). - Str("context", "helm_sdk_repo_index_oci"). - Str("repository", chartPath). - Err(err). - Msg("Failed to obtain repository handle") - return "", errors.Wrap(err, "failed to obtain repository handle") - } - - // List all tags for this chart repository - var tags []string - err = repository.Tags(ctx, "", func(t []string) error { - tags = append(tags, t...) - return nil - }) - if err != nil { - log.Error(). - Str("context", "helm_sdk_repo_index_oci"). - Str("repository", chartPath). - Err(err). - Msg("Failed to list tags") - return "", errors.Wrap(err, "failed to list tags for repository") - } - - if len(tags) == 0 { - return "", errors.Errorf("no tags found for repository %s", chartPath) - } - - // Build Helm index file in memory - indexFile := repo.NewIndexFile() - - const helmConfigMediaType = "application/vnd.cncf.helm.config.v1+json" - - for _, tag := range tags { - chartVersion, err := processOCITag(ctx, repository, registry, chartPath, tag, helmConfigMediaType) - if err != nil { - log.Debug(). - Str("context", "helm_sdk_repo_index_oci"). - Str("repository", chartPath). - Str("tag", tag). - Err(err). - Msg("Failed to process tag; skipping") - continue - } - - if chartVersion != nil { - indexFile.Entries[chartVersion.Name] = append(indexFile.Entries[chartVersion.Name], chartVersion) - } - } - - if len(indexFile.Entries) == 0 { - return "", errors.Errorf("no helm chart versions found for repository %s", chartPath) - } - - indexFile.SortEntries() - - fileNameSafe := strings.ReplaceAll(chartPath, "/", "-") - destPath := filepath.Join(repoSettings.RepositoryCache, fmt.Sprintf("%s-%d-index.yaml", fileNameSafe, time.Now().UnixNano())) - - if err := indexFile.WriteFile(destPath, 0644); err != nil { - return "", errors.Wrap(err, "failed to write OCI index file") - } - - log.Debug(). - Str("context", "helm_sdk_repo_index_oci"). - Str("dest_path", destPath). - Int("entries", len(indexFile.Entries)). - Msg("Successfully generated OCI index file") - - return destPath, nil -} - -// processOCITag processes a single OCI tag and returns a Helm chart version. -func processOCITag(ctx context.Context, repository registry.Repository, registry *portainer.Registry, chartPath string, tag string, helmConfigMediaType string) (*repo.ChartVersion, error) { - // Resolve tag to get descriptor - descriptor, err := repository.Resolve(ctx, tag) - if err != nil { - log.Debug(). - Str("context", "helm_sdk_repo_index_oci"). - Str("repository", chartPath). - Str("tag", tag). - Err(err). - Msg("Failed to resolve tag; skipping") - return nil, nil - } - - // Fetch manifest to validate media type and obtain config descriptor - manifestReader, err := repository.Manifests().Fetch(ctx, descriptor) - if err != nil { - log.Debug(). - Str("context", "helm_sdk_repo_index_oci"). - Str("repository", chartPath). - Str("tag", tag). - Err(err). - Msg("Failed to fetch manifest; skipping") - return nil, nil - } - - manifestContent, err := io.ReadAll(manifestReader) - manifestReader.Close() - if err != nil { - return nil, nil - } - - var manifest ocispec.Manifest - if err := json.Unmarshal(manifestContent, &manifest); err != nil { - return nil, nil - } - - // Ensure manifest config is Helm chart metadata - if manifest.Config.MediaType != helmConfigMediaType { - return nil, nil - } - - // Fetch config blob (chart metadata) - cfgReader, err := repository.Blobs().Fetch(ctx, manifest.Config) - if err != nil { - return nil, nil - } - cfgBytes, err := io.ReadAll(cfgReader) - cfgReader.Close() - if err != nil { - return nil, nil - } - - var metadata chart.Metadata - if err := json.Unmarshal(cfgBytes, &metadata); err != nil { - return nil, nil - } - - // Build chart version entry - chartVersion := &repo.ChartVersion{ - Metadata: &metadata, - URLs: []string{fmt.Sprintf("oci://%s/%s:%s", registry.URL, chartPath, tag)}, - Created: time.Now(), - Digest: descriptor.Digest.String(), - } - - return chartVersion, nil + return indexFile, nil } // convertIndexToResponse converts the Helm index file to our response format. @@ -459,7 +248,7 @@ func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIn // Convert Helm SDK types to our response types for name, charts := range indexFile.Entries { - if chartName == "" || strings.Contains(strings.ToLower(chartName), strings.ToLower(name)) { + if chartName == "" || name == chartName { result.Entries[name] = convertChartsToChartInfo(charts) } } @@ -505,6 +294,87 @@ type ChartInfo struct { Annotations any `json:"annotations,omitempty"` } +// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist +func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error { + log.Debug(). + Str("context", "helm_sdk_dirs"). + Msg("Ensuring Helm directories exist") + + // List of directories to ensure exist + directories := []string{ + filepath.Dir(settings.RepositoryConfig), // Repository config directory + settings.RepositoryCache, // Repository cache directory + filepath.Dir(settings.RegistryConfig), // Registry config directory + settings.PluginsDirectory, // Plugins directory + } + + // Create each directory if it doesn't exist + for _, dir := range directories { + if dir == "" { + continue // Skip empty paths + } + + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0700); err != nil { + log.Error(). + Str("context", "helm_sdk_dirs"). + Str("directory", dir). + Err(err). + Msg("Failed to create directory") + return errors.Wrapf(err, "failed to create directory: %s", dir) + } + } + } + + // Ensure registry config file exists + if settings.RegistryConfig != "" { + if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) { + // Create the directory if it doesn't exist + dir := filepath.Dir(settings.RegistryConfig) + if err := os.MkdirAll(dir, 0700); err != nil { + log.Error(). + Str("context", "helm_sdk_dirs"). + Str("directory", dir). + Err(err). + Msg("Failed to create directory") + return errors.Wrapf(err, "failed to create directory: %s", dir) + } + + // Create an empty registry config file + if _, err := os.Create(settings.RegistryConfig); err != nil { + log.Error(). + Str("context", "helm_sdk_dirs"). + Str("file", settings.RegistryConfig). + Err(err). + Msg("Failed to create registry config file") + return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig) + } + } + } + + // Ensure repository config file exists + if settings.RepositoryConfig != "" { + if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) { + // Create an empty repository config file with default yaml structure + f := repo.NewFile() + if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil { + log.Error(). + Str("context", "helm_sdk_dirs"). + Str("file", settings.RepositoryConfig). + Err(err). + Msg("Failed to create repository config file") + return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig) + } + } + } + + log.Debug(). + Str("context", "helm_sdk_dirs"). + Msg("Successfully ensured all Helm directories exist") + + return nil +} + func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) { // Convert the index file to our response format result, err := convertIndexToResponse(indexFile, chartName) diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go index 70e97e364..4dc9c402d 100644 --- a/pkg/libhelm/sdk/show.go +++ b/pkg/libhelm/sdk/show.go @@ -2,20 +2,20 @@ package sdk import ( "fmt" + "os" "github.com/pkg/errors" - "github.com/portainer/portainer/pkg/libhelm/cache" "github.com/portainer/portainer/pkg/libhelm/options" "github.com/rs/zerolog/log" "helm.sh/helm/v3/pkg/action" ) -var errRequiredShowOptions = errors.New("chart, output format and either repo or registry are required") +var errRequiredShowOptions = errors.New("chart, repo and output format are required") // Show implements the HelmPackageManager interface by using the Helm SDK to show chart information. // It supports showing chart values, readme, and chart details based on the provided ShowOptions. func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) { - if showOpts.Chart == "" || (showOpts.Repo == "" && IsHTTPRepository(showOpts.Registry)) || showOpts.OutputFormat == "" { + if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" { log.Error(). Str("context", "HelmClient"). Str("chart", showOpts.Chart). @@ -32,12 +32,25 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e Str("output_format", string(showOpts.OutputFormat)). Msg("Showing chart information") + // Initialize action configuration (no namespace or cluster access needed) actionConfig := new(action.Configuration) - err := authenticateChartSource(actionConfig, showOpts.Registry) + err := hspm.initActionConfig(actionConfig, "", nil) if err != nil { - return nil, fmt.Errorf("failed to setup chart source: %w", err) + // error is already logged in initActionConfig + return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) } + // Create temporary directory for chart download + tempDir, err := os.MkdirTemp("", "helm-show-*") + if err != nil { + log.Error(). + Str("context", "HelmClient"). + Err(err). + Msg("Failed to create temp directory") + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + // Create showClient action showClient, err := initShowClient(actionConfig, showOpts) if err != nil { @@ -48,28 +61,21 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e return nil, fmt.Errorf("failed to initialize helm show client: %w", err) } - chartRef, _, err := parseChartRef(showOpts.Chart, showOpts.Repo, showOpts.Registry) - if err != nil { - return nil, fmt.Errorf("failed to parse chart reference: %w", err) - } - chartPath, err := showClient.ChartPathOptions.LocateChart(chartRef, hspm.settings) + // Locate and load the chart + log.Debug(). + Str("context", "HelmClient"). + Str("chart", showOpts.Chart). + Str("repo", showOpts.Repo). + Msg("Locating chart") + + chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings) if err != nil { log.Error(). Str("context", "HelmClient"). - Str("chart", chartRef). + Str("chart", showOpts.Chart). Str("repo", showOpts.Repo). Err(err). Msg("Failed to locate chart") - - // Check if this is an authentication error and flush cache if needed - if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) { - cache.FlushRegistryByID(showOpts.Registry.ID) - log.Info(). - Int("registry_id", int(showOpts.Registry.ID)). - Str("context", "HelmClient"). - Msg("Flushed registry cache due to chart registry authentication error") - } - return nil, fmt.Errorf("failed to locate chart: %w", err) } @@ -82,16 +88,6 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e Str("output_format", string(showOpts.OutputFormat)). Err(err). Msg("Failed to show chart info") - - // Check if this is an authentication error and flush cache if needed - if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) { - cache.FlushRegistryByID(showOpts.Registry.ID) - log.Info(). - Int("registry_id", int(showOpts.Registry.ID)). - Str("context", "HelmClient"). - Msg("Flushed registry cache due to chart show authentication error") - } - return nil, fmt.Errorf("failed to show chart info: %w", err) } @@ -108,13 +104,13 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e // and return the show client. func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) { showClient := action.NewShowWithConfig(action.ShowAll, actionConfig) - err := configureChartPathOptions(&showClient.ChartPathOptions, showOpts.Version, showOpts.Repo, showOpts.Registry) - if err != nil { - return nil, fmt.Errorf("failed to configure chart path options: %w", err) - } + showClient.ChartPathOptions.RepoURL = showOpts.Repo + showClient.ChartPathOptions.Version = showOpts.Version // If version is "", it will use the latest version // Set output type based on ShowOptions switch showOpts.OutputFormat { + case options.ShowAll: + showClient.OutputFormat = action.ShowAll case options.ShowChart: showClient.OutputFormat = action.ShowChart case options.ShowValues: diff --git a/pkg/libhelm/sdk/show_test.go b/pkg/libhelm/sdk/show_test.go index 26801eb93..302f188ca 100644 --- a/pkg/libhelm/sdk/show_test.go +++ b/pkg/libhelm/sdk/show_test.go @@ -28,7 +28,7 @@ func Test_Show(t *testing.T) { }) } - t.Run("show requires chart, output format and repo or registry", func(t *testing.T) { + t.Run("show requires chart, repo and output format", func(t *testing.T) { showOpts := options.ShowOptions{ Chart: "", Repo: "", @@ -36,7 +36,7 @@ func Test_Show(t *testing.T) { } _, err := hspm.Show(showOpts) is.Error(err, "should return error when required options are missing") - is.Contains(err.Error(), "chart, output format and either repo or registry are required", "error message should indicate required options") + is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options") }) t.Run("show chart values", func(t *testing.T) { diff --git a/pkg/libhelm/sdk/upgrade.go b/pkg/libhelm/sdk/upgrade.go index 698a3e374..b47a439a9 100644 --- a/pkg/libhelm/sdk/upgrade.go +++ b/pkg/libhelm/sdk/upgrade.go @@ -4,7 +4,6 @@ import ( "time" "github.com/pkg/errors" - "github.com/portainer/portainer/pkg/libhelm/cache" "github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/release" "github.com/rs/zerolog/log" @@ -67,12 +66,6 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade") } - // Setup chart source - err = authenticateChartSource(actionConfig, upgradeOpts.Registry) - if err != nil { - return nil, errors.Wrap(err, "failed to setup chart source for helm release upgrade") - } - upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts) if err != nil { log.Error(). @@ -82,7 +75,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade") } - values, err := hspm.getHelmValuesFromFile(upgradeOpts.ValuesFile) + values, err := hspm.GetHelmValuesFromFile(upgradeOpts.ValuesFile) if err != nil { log.Error(). Str("context", "HelmClient"). @@ -91,36 +84,15 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( 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) - if err != nil { - return nil, errors.Wrap(err, "failed to parse chart reference for helm release upgrade") - } - chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, chartRef, upgradeOpts.Version, repoURL, upgradeClient.DependencyUpdate, "release upgrade") + chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Version, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade") if err != nil { log.Error(). Str("context", "HelmClient"). Err(err). Msg("Failed to load and validate chart for helm release upgrade") - - // Check if this is an authentication error and flush cache if needed - if upgradeOpts.Registry != nil && shouldFlushCacheOnError(err, upgradeOpts.Registry.ID) { - cache.FlushRegistryByID(upgradeOpts.Registry.ID) - log.Info(). - Int("registry_id", int(upgradeOpts.Registry.ID)). - Str("context", "HelmClient"). - Msg("Flushed registry cache due to chart loading authentication error during upgrade") - } - return nil, errors.Wrap(err, "failed to load and validate chart for helm release upgrade") } - // Add chart references to annotations - var registryID int - if upgradeOpts.Registry != nil { - registryID = int(upgradeOpts.Registry.ID) - } - chart.Metadata.Annotations = appendChartReferenceAnnotations(upgradeOpts.Chart, upgradeOpts.Repo, registryID, chart.Metadata.Annotations) - log.Info(). Str("context", "HelmClient"). Str("chart", upgradeOpts.Chart). @@ -145,10 +117,9 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) ( Namespace: helmRelease.Namespace, Chart: release.Chart{ Metadata: &release.Metadata{ - Name: helmRelease.Chart.Metadata.Name, - Version: helmRelease.Chart.Metadata.Version, - AppVersion: helmRelease.Chart.Metadata.AppVersion, - Annotations: helmRelease.Chart.Metadata.Annotations, + Name: helmRelease.Chart.Metadata.Name, + Version: helmRelease.Chart.Metadata.Version, + AppVersion: helmRelease.Chart.Metadata.AppVersion, }, }, Labels: helmRelease.Labels, @@ -163,21 +134,12 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I upgradeClient := action.NewUpgrade(actionConfig) upgradeClient.DependencyUpdate = true upgradeClient.Atomic = upgradeOpts.Atomic + upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo upgradeClient.Wait = upgradeOpts.Wait - upgradeClient.Version = upgradeOpts.Version - upgradeClient.DryRun = upgradeOpts.DryRun - err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry) - if err != nil { - return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade") - } // Set default values if not specified if upgradeOpts.Timeout == 0 { - if upgradeClient.Atomic { - upgradeClient.Timeout = 30 * time.Minute // the atomic flag significantly increases the upgrade time - } else { - upgradeClient.Timeout = 15 * time.Minute - } + upgradeClient.Timeout = 5 * time.Minute } else { upgradeClient.Timeout = upgradeOpts.Timeout } diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go index 7e3e5a07e..8dc6325a3 100644 --- a/pkg/libhelm/sdk/values.go +++ b/pkg/libhelm/sdk/values.go @@ -11,9 +11,9 @@ import ( "helm.sh/helm/v3/pkg/action" ) -// 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 (hspm *HelmSDKPackageManager) 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(). diff --git a/pkg/libkubectl/apply.go b/pkg/libkubectl/apply.go index 23619e532..5dad5adc5 100644 --- a/pkg/libkubectl/apply.go +++ b/pkg/libkubectl/apply.go @@ -6,30 +6,18 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/apply" - cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) Apply(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) - var fatalErr error - cmdutil.BehaviorOnFatal(func(msg string, code int) { - fatalErr = newKubectlFatalError(code, msg) - }) - defer cmdutil.DefaultBehaviorOnFatal() - cmd := apply.NewCmdApply("kubectl", c.factory, c.streams) cmd.SetArgs(resourcesToArgs(manifests)) cmd.SetOut(buf) - err := cmd.ExecuteContext(ctx) - // check for the fatal error first so we don't return the error from the command execution - if fatalErr != nil { - return "", fatalErr - } - // if there is no fatal error, return the error from the command execution - if err != nil { + if err := cmd.ExecuteContext(ctx); err != nil { return "", fmt.Errorf("error applying resources: %w", err) } + return buf.String(), nil } diff --git a/pkg/libkubectl/client.go b/pkg/libkubectl/client.go index 0229c8167..f9d4137f6 100644 --- a/pkg/libkubectl/client.go +++ b/pkg/libkubectl/client.go @@ -3,7 +3,6 @@ package libkubectl import ( "bytes" "errors" - "fmt" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericiooptions" @@ -62,7 +61,3 @@ func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecu return configFlags, nil } - -func newKubectlFatalError(code int, msg string) error { - return fmt.Errorf("kubectl fatal error (exit code %d): %s", code, msg) -} diff --git a/pkg/libkubectl/delete.go b/pkg/libkubectl/delete.go index 4544dfc76..bb093ab4d 100644 --- a/pkg/libkubectl/delete.go +++ b/pkg/libkubectl/delete.go @@ -6,30 +6,17 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/delete" - cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) Delete(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) - var fatalErr error - cmdutil.BehaviorOnFatal(func(msg string, code int) { - fatalErr = newKubectlFatalError(code, msg) - }) - defer cmdutil.DefaultBehaviorOnFatal() - cmd := delete.NewCmdDelete(c.factory, c.streams) cmd.SetArgs(resourcesToArgs(manifests)) cmd.Flags().Set("ignore-not-found", "true") cmd.SetOut(buf) - err := cmd.ExecuteContext(ctx) - // check for the fatal error first so we don't return the error from the command execution - if fatalErr != nil { - return "", fatalErr - } - // if there is no fatal error, return the error from the command execution - if err != nil { + if err := cmd.ExecuteContext(ctx); err != nil { return "", fmt.Errorf("error deleting resources: %w", err) } diff --git a/pkg/libkubectl/restart.go b/pkg/libkubectl/restart.go index 9ce7149aa..fcfa7fd15 100644 --- a/pkg/libkubectl/restart.go +++ b/pkg/libkubectl/restart.go @@ -6,18 +6,11 @@ import ( "fmt" "k8s.io/kubectl/pkg/cmd/rollout" - cmdutil "k8s.io/kubectl/pkg/cmd/util" ) func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string, error) { buf := new(bytes.Buffer) - var fatalErr error - cmdutil.BehaviorOnFatal(func(msg string, code int) { - fatalErr = newKubectlFatalError(code, msg) - }) - defer cmdutil.DefaultBehaviorOnFatal() - cmd := rollout.NewCmdRollout(c.factory, c.streams) args := []string{"restart"} args = append(args, resourcesToArgs(manifests)...) @@ -25,13 +18,7 @@ func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string cmd.SetArgs(args) cmd.SetOut(buf) - err := cmd.ExecuteContext(ctx) - // check for the fatal error first so we don't return the error from the command execution - if fatalErr != nil { - return "", fatalErr - } - // if there is no fatal error, return the error from the command execution - if err != nil { + if err := cmd.ExecuteContext(ctx); err != nil { return "", fmt.Errorf("error restarting resources: %w", err) } diff --git a/pkg/liboras/generic_listrepo_client.go b/pkg/liboras/generic_listrepo_client.go deleted file mode 100644 index a99587c0e..000000000 --- a/pkg/liboras/generic_listrepo_client.go +++ /dev/null @@ -1,47 +0,0 @@ -package liboras - -import ( - "context" - "errors" - - portainer "github.com/portainer/portainer/api" - "oras.land/oras-go/v2/registry/remote" -) - -// GenericListRepoClient implements RepositoryListClient for standard OCI registries -// This client handles repository listing for registries that follow the standard OCI distribution spec -type GenericListRepoClient struct { - registry *portainer.Registry - registryClient *remote.Registry -} - -// NewGenericListRepoClient creates a new generic repository listing client -func NewGenericListRepoClient(registry *portainer.Registry) *GenericListRepoClient { - return &GenericListRepoClient{ - registry: registry, - // registryClient will be set when needed - } -} - -// SetRegistryClient sets the ORAS registry client for repository listing operations -func (c *GenericListRepoClient) SetRegistryClient(registryClient *remote.Registry) { - c.registryClient = registryClient -} - -// ListRepositories fetches repositories from a standard OCI registry using ORAS -func (c *GenericListRepoClient) ListRepositories(ctx context.Context) ([]string, error) { - if c.registryClient == nil { - return nil, errors.New("registry client not initialized for repository listing") - } - - var repositories []string - err := c.registryClient.Repositories(ctx, "", func(repos []string) error { - repositories = append(repositories, repos...) - return nil - }) - if err != nil { - return nil, errors.New("failed to list repositories") - } - - return repositories, nil -} diff --git a/pkg/liboras/github_listrepo_client.go b/pkg/liboras/github_listrepo_client.go deleted file mode 100644 index e80789bec..000000000 --- a/pkg/liboras/github_listrepo_client.go +++ /dev/null @@ -1,57 +0,0 @@ -package liboras - -import ( - "context" - "fmt" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/github" - "github.com/rs/zerolog/log" -) - -// GithubListRepoClient implements RepositoryListClient specifically for GitHub registries -// This client handles the GitHub Packages API's unique repository listing implementation -type GithubListRepoClient struct { - registry *portainer.Registry - client *github.Client -} - -// NewGithubListRepoClient creates a new GitHub repository listing client -func NewGithubListRepoClient(registry *portainer.Registry) *GithubListRepoClient { - // Prefer the management configuration credentials when available - token := registry.Password - if registry.ManagementConfiguration != nil && registry.ManagementConfiguration.Password != "" { - token = registry.ManagementConfiguration.Password - } - - client := github.NewClient(token) - - return &GithubListRepoClient{ - registry: registry, - client: client, - } -} - -// ListRepositories fetches repositories from a GitHub registry using the GitHub Packages API -func (c *GithubListRepoClient) ListRepositories(ctx context.Context) ([]string, error) { - repositories, err := c.client.GetContainerPackages( - ctx, - c.registry.Github.UseOrganisation, - c.registry.Github.OrganisationName, - ) - if err != nil { - log.Error(). - Str("registry_name", c.registry.Name). - Err(err). - Msg("Failed to list GitHub repositories") - return nil, fmt.Errorf("failed to list GitHub repositories: %w", err) - } - - log.Debug(). - Bool("use_organisation", c.registry.Github.UseOrganisation). - Str("organisation_name", c.registry.Github.OrganisationName). - Int("repository_count", len(repositories)). - Msg("Successfully listed GitHub repositories") - - return repositories, nil -} diff --git a/pkg/liboras/gitlab_listrepo_client.go b/pkg/liboras/gitlab_listrepo_client.go deleted file mode 100644 index 762d4984e..000000000 --- a/pkg/liboras/gitlab_listrepo_client.go +++ /dev/null @@ -1,47 +0,0 @@ -package liboras - -import ( - "context" - "fmt" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/proxy/factory/gitlab" - "github.com/rs/zerolog/log" -) - -// GitlabListRepoClient implements RepositoryListClient specifically for GitLab registries -// This client handles the GitLab Container Registry API's unique repository listing implementation -type GitlabListRepoClient struct { - registry *portainer.Registry - client *gitlab.Client -} - -// NewGitlabListRepoClient creates a new GitLab repository listing client -func NewGitlabListRepoClient(registry *portainer.Registry) *GitlabListRepoClient { - client := gitlab.NewClient(registry.Gitlab.InstanceURL, registry.Password) - - return &GitlabListRepoClient{ - registry: registry, - client: client, - } -} - -// ListRepositories fetches repositories from a GitLab registry using the GitLab API -func (c *GitlabListRepoClient) ListRepositories(ctx context.Context) ([]string, error) { - repositories, err := c.client.GetRegistryRepositoryNames(ctx, c.registry.Gitlab.ProjectID) - if err != nil { - log.Error(). - Str("registry_name", c.registry.Name). - Err(err). - Msg("Failed to list GitLab repositories") - return nil, fmt.Errorf("failed to list GitLab repositories: %w", err) - } - - log.Debug(). - Str("gitlab_url", c.registry.Gitlab.InstanceURL). - Int("project_id", c.registry.Gitlab.ProjectID). - Int("repository_count", len(repositories)). - Msg("Successfully listed GitLab repositories") - - return repositories, nil -} diff --git a/pkg/liboras/listrepo_client.go b/pkg/liboras/listrepo_client.go deleted file mode 100644 index f3e066de4..000000000 --- a/pkg/liboras/listrepo_client.go +++ /dev/null @@ -1,39 +0,0 @@ -package liboras - -import ( - "context" - - portainer "github.com/portainer/portainer/api" - "oras.land/oras-go/v2/registry/remote" -) - -// RepositoryListClient provides an interface specifically for listing repositories -// This exists because listing repositories isn't a standard OCI operation, and we need to handle -// different registry types differently. -type RepositoryListClient interface { - // ListRepositories returns a list of repository names from the registry - ListRepositories(ctx context.Context) ([]string, error) -} - -// RepositoryListClientFactory creates repository listing clients based on registry type -type RepositoryListClientFactory struct{} - -// NewRepositoryListClientFactory creates a new factory instance -func NewRepositoryListClientFactory() *RepositoryListClientFactory { - return &RepositoryListClientFactory{} -} - -// CreateListClientWithRegistry creates a repository listing client based on the registry type -// and automatically configures it with the provided ORAS registry client for generic registries -func (f *RepositoryListClientFactory) CreateListClientWithRegistry(registry *portainer.Registry, registryClient *remote.Registry) (RepositoryListClient, error) { - switch registry.Type { - case portainer.GitlabRegistry: - return NewGitlabListRepoClient(registry), nil - case portainer.GithubRegistry: - return NewGithubListRepoClient(registry), nil - default: - genericClient := NewGenericListRepoClient(registry) - genericClient.SetRegistryClient(registryClient) - return genericClient, nil - } -} diff --git a/pkg/liboras/registry.go b/pkg/liboras/registry.go deleted file mode 100644 index 576f848ac..000000000 --- a/pkg/liboras/registry.go +++ /dev/null @@ -1,79 +0,0 @@ -package liboras - -import ( - "strings" - - portainer "github.com/portainer/portainer/api" - "github.com/rs/zerolog/log" - "oras.land/oras-go/v2/registry/remote" - "oras.land/oras-go/v2/registry/remote/auth" - "oras.land/oras-go/v2/registry/remote/retry" -) - -func CreateClient(registry portainer.Registry) (*remote.Registry, error) { - registryClient, err := remote.NewRegistry(registry.URL) - if err != nil { - log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client") - return nil, err - } - // By default, oras sends multiple requests to get the full list of repos/tags/referrers. - // set a high page size limit for fewer round trips. - // e.g. https://github.com/oras-project/oras-go/blob/v2.6.0/registry/remote/registry.go#L129-L142 - registryClient.RepositoryListPageSize = 1000 - registryClient.TagListPageSize = 1000 - registryClient.ReferrerListPageSize = 1000 - - // Only apply authentication if explicitly enabled AND credentials are provided - if registry.Authentication && - strings.TrimSpace(registry.Username) != "" && - strings.TrimSpace(registry.Password) != "" { - - registryClient.Client = &auth.Client{ - Client: retry.DefaultClient, - Cache: auth.NewCache(), - Credential: auth.StaticCredential(registry.URL, auth.Credential{ - Username: registry.Username, - Password: registry.Password, - }), - } - - log.Debug(). - Str("registryURL", registry.URL). - Str("registryType", getRegistryTypeName(registry.Type)). - Bool("authentication", true). - Msg("Created ORAS registry client with authentication") - } else { - // Use default client for anonymous access - registryClient.Client = retry.DefaultClient - - log.Debug(). - Str("registryURL", registry.URL). - Str("registryType", getRegistryTypeName(registry.Type)). - Bool("authentication", false). - Msg("Created ORAS registry client for anonymous access") - } - - return registryClient, nil -} - -// getRegistryTypeName returns a human-readable name for the registry type -func getRegistryTypeName(registryType portainer.RegistryType) string { - switch registryType { - case portainer.QuayRegistry: - return "Quay" - case portainer.AzureRegistry: - return "Azure" - case portainer.CustomRegistry: - return "Custom" - case portainer.GitlabRegistry: - return "GitLab" - case portainer.ProGetRegistry: - return "ProGet" - case portainer.DockerHubRegistry: - return "DockerHub" - case portainer.EcrRegistry: - return "ECR" - default: - return "Unknown" - } -} diff --git a/pkg/liboras/registry_test.go b/pkg/liboras/registry_test.go deleted file mode 100644 index 78172f25d..000000000 --- a/pkg/liboras/registry_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package liboras - -import ( - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/stretchr/testify/assert" - "oras.land/oras-go/v2/registry/remote/auth" - "oras.land/oras-go/v2/registry/remote/retry" -) - -func TestCreateClient_AuthenticationScenarios(t *testing.T) { - tests := []struct { - name string - registry portainer.Registry - expectAuthenticated bool - description string - }{ - { - name: "authentication disabled should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: false, - Username: "testuser", - Password: "testpass", - }, - expectAuthenticated: false, - description: "Even with credentials present, authentication=false should result in anonymous access", - }, - { - name: "authentication enabled with valid credentials should create authenticated client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: "testuser", - Password: "testpass", - }, - expectAuthenticated: true, - description: "Valid credentials with authentication=true should result in authenticated access", - }, - { - name: "authentication enabled with empty username should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: "", - Password: "testpass", - }, - expectAuthenticated: false, - description: "Empty username should fallback to anonymous access", - }, - { - name: "authentication enabled with whitespace-only username should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: " ", - Password: "testpass", - }, - expectAuthenticated: false, - description: "Whitespace-only username should fallback to anonymous access", - }, - { - name: "authentication enabled with empty password should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: "testuser", - Password: "", - }, - expectAuthenticated: false, - description: "Empty password should fallback to anonymous access", - }, - { - name: "authentication enabled with whitespace-only password should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: "testuser", - Password: " ", - }, - expectAuthenticated: false, - description: "Whitespace-only password should fallback to anonymous access", - }, - { - name: "authentication enabled with both credentials empty should create anonymous client", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: true, - Username: "", - Password: "", - }, - expectAuthenticated: false, - description: "Both credentials empty should fallback to anonymous access", - }, - { - name: "public registry with no authentication should create anonymous client", - registry: portainer.Registry{ - URL: "docker.io", - Authentication: false, - Username: "", - Password: "", - }, - expectAuthenticated: false, - description: "Public registries without authentication should use anonymous access", - }, - { - name: "GitLab registry with valid credentials should create authenticated client", - registry: portainer.Registry{ - Type: portainer.GitlabRegistry, - URL: "registry.gitlab.com", - Authentication: true, - Username: "gitlab-ci-token", - Password: "glpat-xxxxxxxxxxxxxxxxxxxx", - Gitlab: portainer.GitlabRegistryData{ - ProjectID: 12345, - InstanceURL: "https://gitlab.com", - ProjectPath: "my-group/my-project", - }, - }, - expectAuthenticated: true, - description: "GitLab registry with valid credentials should result in authenticated access", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := CreateClient(tt.registry) - - assert.NoError(t, err, "CreateClient should not return an error") - assert.NotNil(t, client, "Client should not be nil") - - // Check if the client has authentication configured - if tt.expectAuthenticated { - // Should have auth.Client with credentials - authClient, ok := client.Client.(*auth.Client) - assert.True(t, ok, "Expected auth.Client for authenticated access") - assert.NotNil(t, authClient, "Auth client should not be nil") - assert.NotNil(t, authClient.Credential, "Credential function should be set") - } else { - // Should use retry.DefaultClient (no authentication) - assert.Equal(t, retry.DefaultClient, client.Client, - "Expected retry.DefaultClient for anonymous access") - } - }) - } -} - -func TestCreateClient_RegistryTypes(t *testing.T) { - registryTypes := []struct { - name string - registryType portainer.RegistryType - expectedName string - }{ - {"DockerHub", portainer.DockerHubRegistry, "DockerHub"}, - {"Azure", portainer.AzureRegistry, "Azure"}, - {"Custom", portainer.CustomRegistry, "Custom"}, - {"GitLab", portainer.GitlabRegistry, "GitLab"}, - {"Quay", portainer.QuayRegistry, "Quay"}, - {"ProGet", portainer.ProGetRegistry, "ProGet"}, - {"ECR", portainer.EcrRegistry, "ECR"}, - } - - for _, rt := range registryTypes { - t.Run(rt.name, func(t *testing.T) { - registry := portainer.Registry{ - URL: "registry.example.com", - Type: rt.registryType, - Authentication: false, - } - - client, err := CreateClient(registry) - - assert.NoError(t, err, "CreateClient should not return an error") - assert.NotNil(t, client, "Client should not be nil") - - // Verify that getRegistryTypeName returns the expected name - typeName := getRegistryTypeName(rt.registryType) - assert.Equal(t, rt.expectedName, typeName, "Registry type name mismatch") - }) - } -} - -func TestGetRegistryTypeName(t *testing.T) { - tests := []struct { - registryType portainer.RegistryType - expectedName string - }{ - {portainer.QuayRegistry, "Quay"}, - {portainer.AzureRegistry, "Azure"}, - {portainer.CustomRegistry, "Custom"}, - {portainer.GitlabRegistry, "GitLab"}, - {portainer.ProGetRegistry, "ProGet"}, - {portainer.DockerHubRegistry, "DockerHub"}, - {portainer.EcrRegistry, "ECR"}, - {portainer.RegistryType(999), "Unknown"}, // Unknown type - } - - for _, tt := range tests { - t.Run(tt.expectedName, func(t *testing.T) { - result := getRegistryTypeName(tt.registryType) - assert.Equal(t, tt.expectedName, result, "Registry type name mismatch") - }) - } -} - -func TestCreateClient_ErrorHandling(t *testing.T) { - tests := []struct { - name string - registry portainer.Registry - expectError bool - }{ - { - name: "valid registry URL should not error", - registry: portainer.Registry{ - URL: "registry.example.com", - Authentication: false, - }, - expectError: false, - }, - { - name: "empty registry URL should error", - registry: portainer.Registry{ - URL: "", - Authentication: false, - }, - expectError: true, - }, - { - name: "invalid registry URL should error", - registry: portainer.Registry{ - URL: "://invalid-url", - Authentication: false, - }, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := CreateClient(tt.registry) - - if tt.expectError { - assert.Error(t, err, "Expected an error but got none") - assert.Nil(t, client, "Client should be nil when error occurs") - } else { - assert.NoError(t, err, "Expected no error but got: %v", err) - assert.NotNil(t, client, "Client should not be nil") - } - }) - } -} diff --git a/pkg/liboras/repository.go b/pkg/liboras/repository.go deleted file mode 100644 index 2bd78f46a..000000000 --- a/pkg/liboras/repository.go +++ /dev/null @@ -1,126 +0,0 @@ -package liboras - -import ( - "context" - "fmt" - "io" - "sort" - - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/concurrent" - "github.com/segmentio/encoding/json" - "golang.org/x/mod/semver" - "oras.land/oras-go/v2/registry" - "oras.land/oras-go/v2/registry/remote" -) - -// ListRepositories retrieves all repositories from a registry using specialized repository listing clients -// Each registry type has different repository listing implementations that require specific API calls -func ListRepositories(ctx context.Context, registry *portainer.Registry, registryClient *remote.Registry) ([]string, error) { - factory := NewRepositoryListClientFactory() - listClient, err := factory.CreateListClientWithRegistry(registry, registryClient) - if err != nil { - return nil, fmt.Errorf("failed to create repository list client: %w", err) - } - - return listClient.ListRepositories(ctx) -} - -// FilterRepositoriesByMediaType filters repositories to only include those with the expected media type -func FilterRepositoriesByMediaType(ctx context.Context, repositoryNames []string, registryClient *remote.Registry, expectedMediaType string) ([]string, error) { - // Run concurrently as this can take 10s+ to complete in serial - var tasks []concurrent.Func - for _, repoName := range repositoryNames { - name := repoName - task := func(ctx context.Context) (any, error) { - repository, err := registryClient.Repository(ctx, name) - if err != nil { - return nil, err - } - - if HasMediaType(ctx, repository, expectedMediaType) { - return name, nil - } - return nil, nil // not a repository with the expected media type - } - tasks = append(tasks, task) - } - - // 10 is a reasonable max concurrency limit - results, err := concurrent.Run(ctx, 10, tasks...) - if err != nil { - return nil, err - } - - // Collect repository names - var repositories []string - for _, result := range results { - if result.Result != nil { - if repoName, ok := result.Result.(string); ok { - repositories = append(repositories, repoName) - } - } - } - - return repositories, nil -} - -// HasMediaType checks if a repository has artifacts with the specified media type -func HasMediaType(ctx context.Context, repository registry.Repository, expectedMediaType string) bool { - // Check the first available tag - // Reasonable limitation - it won't work for repos where the latest tag is missing the expected media type but other tags have it - // This tradeoff is worth it for the performance benefits - var latestTag string - err := repository.Tags(ctx, "", func(tagList []string) error { - if len(tagList) > 0 { - // Order the taglist by latest semver, then get the latest tag - // e.g. ["1.0", "1.1"] -> ["1.1", "1.0"] -> "1.1" - sort.Slice(tagList, func(i, j int) bool { - return semver.Compare(tagList[i], tagList[j]) > 0 - }) - latestTag = tagList[0] - } - return nil - }) - - if err != nil { - return false - } - - if latestTag == "" { - return false - } - - descriptor, err := repository.Resolve(ctx, latestTag) - if err != nil { - return false - } - - return descriptorHasMediaType(ctx, repository, descriptor, expectedMediaType) -} - -// descriptorHasMediaType checks if a descriptor or its manifest contains the expected media type -func descriptorHasMediaType(ctx context.Context, repository registry.Repository, descriptor ocispec.Descriptor, expectedMediaType string) bool { - // Check if the descriptor indicates the expected media type - if descriptor.MediaType == expectedMediaType { - return true - } - - // Otherwise, look for the expected media type in the entire manifest content - manifestReader, err := repository.Manifests().Fetch(ctx, descriptor) - if err != nil { - return false - } - defer manifestReader.Close() - - content, err := io.ReadAll(manifestReader) - if err != nil { - return false - } - var manifest ocispec.Manifest - if err := json.Unmarshal(content, &manifest); err != nil { - return false - } - return manifest.Config.MediaType == expectedMediaType -} diff --git a/pkg/snapshot/docker.go b/pkg/snapshot/docker.go index 12f3a3089..deaabdb08 100644 --- a/pkg/snapshot/docker.go +++ b/pkg/snapshot/docker.go @@ -100,10 +100,7 @@ func dockerSnapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client) snapshot.TotalCPU = int(nanoCpus / 1e9) snapshot.TotalMemory = totalMem - snapshot.NodeCount = 1 - if snapshot.Swarm { - snapshot.NodeCount = len(nodes) - } + snapshot.NodeCount = len(nodes) return nil } diff --git a/pkg/snapshot/kubernetes.go b/pkg/snapshot/kubernetes.go index 77fda14bd..d8e550e84 100644 --- a/pkg/snapshot/kubernetes.go +++ b/pkg/snapshot/kubernetes.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "io" - "math" "os" - "reflect" "strings" "time" @@ -21,11 +19,11 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - statsapi "k8s.io/kubelet/pkg/apis/stats/v1alpha1" ) func CreateKubernetesSnapshot(cli *kubernetes.Clientset) (*portainer.KubernetesSnapshot, error) { kubernetesSnapshot := &portainer.KubernetesSnapshot{} + err := kubernetesSnapshotVersion(kubernetesSnapshot, cli) if err != nil { log.Warn().Err(err).Msg("unable to snapshot cluster version") @@ -56,28 +54,10 @@ func kubernetesSnapshotNodes(snapshot *portainer.KubernetesSnapshot, cli *kubern return err } - if len(nodeList.Items) == 0 { - return nil - } - var totalCPUs, totalMemory int64 - performanceMetrics := &portainer.PerformanceMetrics{ - CPUUsage: 0, - MemoryUsage: 0, - NetworkUsage: 0, - } - for _, node := range nodeList.Items { totalCPUs += node.Status.Capacity.Cpu().Value() totalMemory += node.Status.Capacity.Memory().Value() - - performanceMetrics, err = kubernetesSnapshotNodePerformanceMetrics(cli, node, performanceMetrics) - if err != nil { - return fmt.Errorf("failed to get node performance metrics: %w", err) - } - if performanceMetrics != nil { - snapshot.PerformanceMetrics = performanceMetrics - } } snapshot.TotalCPU = totalCPUs @@ -143,40 +123,6 @@ func kubernetesSnapshotPodErrorLogs(snapshot *portainer.KubernetesSnapshot, cli return nil } -func kubernetesSnapshotNodePerformanceMetrics(cli *kubernetes.Clientset, node corev1.Node, performanceMetrics *portainer.PerformanceMetrics) (*portainer.PerformanceMetrics, error) { - result := cli.RESTClient().Get().AbsPath(fmt.Sprintf("/api/v1/nodes/%s/proxy/stats/summary", node.Name)).Do(context.TODO()) - if result.Error() != nil { - return nil, fmt.Errorf("failed to get node performance metrics: %w", result.Error()) - } - - raw, err := result.Raw() - if err != nil { - return nil, fmt.Errorf("failed to get node performance metrics: %w", err) - } - - stats := statsapi.Summary{} - err = json.Unmarshal(raw, &stats) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal node performance metrics: %w", err) - } - - nodeStats := stats.Node - if reflect.DeepEqual(nodeStats, statsapi.NodeStats{}) { - return nil, nil - } - - if nodeStats.CPU != nil && nodeStats.CPU.UsageNanoCores != nil { - performanceMetrics.CPUUsage += math.Round(float64(*nodeStats.CPU.UsageNanoCores) / float64(node.Status.Capacity.Cpu().Value()*1000000000) * 100) - } - if nodeStats.Memory != nil && nodeStats.Memory.WorkingSetBytes != nil { - performanceMetrics.MemoryUsage += math.Round(float64(*nodeStats.Memory.WorkingSetBytes) / float64(node.Status.Capacity.Memory().Value()) * 100) - } - if nodeStats.Network != nil && nodeStats.Network.RxBytes != nil && nodeStats.Network.TxBytes != nil { - performanceMetrics.NetworkUsage += math.Round((float64(*nodeStats.Network.RxBytes) + float64(*nodeStats.Network.TxBytes)) / 1024 / 1024) // MB - } - return performanceMetrics, nil -} - // filterLogsByPattern filters the logs by the given patterns and returns a list of logs that match the patterns // the logs are returned as a list of maps with the keys "timestamp" and "message" func filterLogsByPattern(logBytes []byte, patterns []string) []map[string]string { diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index 8ad69df72..f647d1e17 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -80,32 +80,3 @@ func IsDNSName(s string) bool { return !IsIP(s) && dnsNameRegex.MatchString(s) } - -func IsTrustedOrigin(s string) bool { - // Reject if a scheme is present - if strings.Contains(s, "://") { - return false - } - - // Prepend http:// for parsing - strTemp := "http://" + s - parsedOrigin, err := url.Parse(strTemp) - if err != nil { - return false - } - - // Validate host, and ensure no user, path, query, fragment, port, etc. - if parsedOrigin.Host == "" || - parsedOrigin.User != nil || - parsedOrigin.Path != "" || - parsedOrigin.RawQuery != "" || - parsedOrigin.Fragment != "" || - parsedOrigin.Opaque != "" || - parsedOrigin.RawFragment != "" || - parsedOrigin.RawPath != "" || - parsedOrigin.Port() != "" { - return false - } - - return true -} diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go index ca054190d..f3cb6a01c 100644 --- a/pkg/validate/validate_test.go +++ b/pkg/validate/validate_test.go @@ -437,64 +437,3 @@ func Test_IsDNSName(t *testing.T) { }) } } - -func Test_IsTrustedOrigin(t *testing.T) { - f := func(s string, expected bool) { - t.Helper() - - result := IsTrustedOrigin(s) - if result != expected { - t.Fatalf("unexpected result for %q; got %t; want %t", s, result, expected) - } - } - - // Valid trusted origins - host only - f("localhost", true) - f("example.com", true) - f("192.168.1.1", true) - f("api.example.com", true) - f("subdomain.example.org", true) - - // Invalid trusted origins - host with port (no longer allowed) - f("localhost:8080", false) - f("example.com:3000", false) - f("192.168.1.1:443", false) - f("api.example.com:9000", false) - - // Invalid trusted origins - empty or malformed - f("", false) - f("invalid url", false) - f("://example.com", false) - - // Invalid trusted origins - with scheme - f("http://example.com", false) - f("https://localhost", false) - f("ftp://192.168.1.1", false) - - // Invalid trusted origins - with user info - f("user@example.com", false) - f("user:pass@localhost", false) - - // Invalid trusted origins - with path - f("example.com/path", false) - f("localhost/api", false) - f("192.168.1.1/static", false) - - // Invalid trusted origins - with query parameters - f("example.com?param=value", false) - f("localhost:8080?query=test", false) - - // Invalid trusted origins - with fragment - f("example.com#fragment", false) - f("localhost:3000#section", false) - - // Invalid trusted origins - with multiple invalid components - f("https://user@example.com/path?query=value#fragment", false) - f("http://localhost:8080/api/v1?param=test", false) - - // Edge cases - ports are no longer allowed - f("example.com:0", false) // port 0 is no longer valid - f("example.com:65535", false) // max port number is no longer valid - f("example.com:99999", false) // invalid port number - f("example.com:-1", false) // negative port -} diff --git a/yarn.lock b/yarn.lock index 3e826163f..3bdc96e8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12868,10 +12868,10 @@ klona@^2.0.6: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== -kubernetes-types@^1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" - integrity sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== +kubernetes-types@^1.26.0: + version "1.26.0" + resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.26.0.tgz#47b7db20eb084931cfebf67937cc6b9091dc3da3" + integrity sha512-jv0XaTIGW/p18jaiKRD85hLTYWx0yEj+cb6PDX3GdNa3dWoRxnD4Gv7+bE6C/ehcsp2skcdy34vT25jbPofDIQ== kuler@^2.0.0: version "2.0.0"