diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index 9daaf0ec..eb7efb73 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -30,6 +30,7 @@ type DiffKey struct { type Helm struct { Charts []string Repo []string + RegistryLoginHost string // Captures the registry host for OCI login Releases []Release Deleted []Release Linted []Release @@ -104,6 +105,7 @@ func (helm *Helm) UpdateRepo() error { return nil } func (helm *Helm) RegistryLogin(name, username, password, caFile, certFile, keyFile string, skipTLSVerify bool) error { + helm.RegistryLoginHost = name return nil } func (helm *Helm) SyncRelease(context helmexec.HelmContext, name, chart, namespace string, flags ...string) error { diff --git a/pkg/state/state.go b/pkg/state/state.go index d8e4013c..f3757142 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -592,7 +592,10 @@ func (st *HelmState) SyncRepos(helm RepoUpdater, shouldSkip map[string]bool) ([] username, password := gatherUsernamePassword(repo.Name, repo.Username, repo.Password) var err error if repo.OCI { - err = helm.RegistryLogin(repo.URL, username, password, repo.CaFile, repo.CertFile, repo.KeyFile, repo.SkipTLSVerify) + // For OCI registries, extract just the registry host for login + // helm registry login expects "registry.io" not "registry.io/org/path" + registryHost := extractRegistryHost(repo.URL) + err = helm.RegistryLogin(registryHost, username, password, repo.CaFile, repo.CertFile, repo.KeyFile, repo.SkipTLSVerify) } else { err = helm.AddRepo(repo.Name, repo.URL, repo.CaFile, repo.CertFile, repo.KeyFile, username, password, repo.Managed, repo.PassCredentials, repo.SkipTLSVerify) } @@ -607,6 +610,23 @@ func (st *HelmState) SyncRepos(helm RepoUpdater, shouldSkip map[string]bool) ([] return updated, nil } +// extractRegistryHost extracts the registry hostname (and optional port) from a URL. +// For OCI registries, helm registry login requires just the host, not the full path. +// Examples: +// - "quay.io/org/repo" -> "quay.io" +// - "registry:443/helm-charts" -> "registry:443" +// - "myregistry.azurecr.io" -> "myregistry.azurecr.io" +func extractRegistryHost(repoURL string) string { + // Find the first slash after the initial part + slashIndex := strings.Index(repoURL, "/") + if slashIndex == -1 { + // No slash, return the whole URL + return repoURL + } + // Return everything before the first slash + return repoURL[:slashIndex] +} + func gatherUsernamePassword(repoName string, username string, password string) (string, string) { var user, pass string diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index a02ea593..6846a0df 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -1248,6 +1248,103 @@ func TestHelmState_SyncRepos(t *testing.T) { } } +func TestHelmState_SyncRepos_OCI(t *testing.T) { + tests := []struct { + name string + repos []RepositorySpec + envs map[string]string + wantRegistryHost string + }{ + { + name: "OCI registry with organization path", + repos: []RepositorySpec{ + { + Name: "ociregistry", + URL: "quay.io/ORG_NAME", + OCI: true, + Username: "testuser", + Password: "testpass", + }, + }, + wantRegistryHost: "quay.io", + }, + { + name: "OCI registry with multiple path segments", + repos: []RepositorySpec{ + { + Name: "myregistry", + URL: "registry.example.com/org/team/repo", + OCI: true, + Username: "user", + Password: "pass", + }, + }, + wantRegistryHost: "registry.example.com", + }, + { + name: "OCI registry without path", + repos: []RepositorySpec{ + { + Name: "simpleregistry", + URL: "myregistry.azurecr.io", + OCI: true, + Username: "azureuser", + Password: "azurepass", + }, + }, + wantRegistryHost: "myregistry.azurecr.io", + }, + { + name: "OCI registry with port and path", + repos: []RepositorySpec{ + { + Name: "localregistry", + URL: "localhost:5000/charts", + OCI: true, + Username: "local", + Password: "local", + }, + }, + wantRegistryHost: "localhost:5000", + }, + { + name: "OCI registry with environment credentials", + repos: []RepositorySpec{ + { + Name: "envregistry", + URL: "registry.io/org", + OCI: true, + }, + }, + envs: map[string]string{ + "ENVREGISTRY_USERNAME": "envuser", + "ENVREGISTRY_PASSWORD": "envpass", + }, + wantRegistryHost: "registry.io", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.envs { + t.Setenv(k, v) + } + helm := &exectest.Helm{} + state := &HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: tt.repos, + }, + } + _, err := state.SyncRepos(helm, map[string]bool{}) + if err != nil { + t.Errorf("HelmState.SyncRepos() error = %v", err) + } + if helm.RegistryLoginHost != tt.wantRegistryHost { + t.Errorf("HelmState.SyncRepos() RegistryLoginHost = %q, want %q", helm.RegistryLoginHost, tt.wantRegistryHost) + } + }) + } +} + func TestHelmState_SyncReleases(t *testing.T) { postRenderer := "foo.sh" tests := []struct { @@ -3095,6 +3192,53 @@ func TestReverse(t *testing.T) { } } +func Test_extractRegistryHost(t *testing.T) { + tests := []struct { + name string + repoURL string + expected string + }{ + { + name: "URL with organization and repository path", + repoURL: "quay.io/ORG_NAME", + expected: "quay.io", + }, + { + name: "URL with registry port and path", + repoURL: "registry:443/helm-charts", + expected: "registry:443", + }, + { + name: "URL with multiple path segments", + repoURL: "registry.io/org/repo/subpath", + expected: "registry.io", + }, + { + name: "URL with registry only, no path", + repoURL: "myregistry.azurecr.io", + expected: "myregistry.azurecr.io", + }, + { + name: "URL with registry and port only", + repoURL: "localhost:5000", + expected: "localhost:5000", + }, + { + name: "Docker Hub style URL", + repoURL: "registry-1.docker.io/library", + expected: "registry-1.docker.io", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractRegistryHost(tt.repoURL) + if got != tt.expected { + t.Errorf("extractRegistryHost(%q) = %q, want %q", tt.repoURL, got, tt.expected) + } + }) + } +} + func Test_gatherUsernamePassword(t *testing.T) { type args struct { repoName string