diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 106d24336..065273f79 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -77,6 +77,8 @@ type EnvSettings struct { Debug bool // RegistryConfig is the path to the registry config file. RegistryConfig string + // RegistryAliasConfig is the path to the registry alias config file. + RegistryAliasConfig string // RepositoryConfig is the path to the repositories file. RepositoryConfig string // RepositoryCache is the path to the repository cache directory. @@ -109,6 +111,7 @@ func New() *EnvSettings { KubeInsecureSkipTLSVerify: envBoolOr("HELM_KUBEINSECURE_SKIP_TLS_VERIFY", false), PluginsDirectory: envOr("HELM_PLUGINS", helmpath.DataPath("plugins")), RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")), + RegistryAliasConfig: envOr("HELM_REGISTRY_ALIAS_CONFIG", helmpath.ConfigPath("registry/aliases.yaml")), RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")), @@ -162,6 +165,7 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "if true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure") fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output") fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file") + fs.StringVar(&s.RegistryAliasConfig, "registry-alias-config", s.RegistryAliasConfig, "path to the registry alias config file") fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs") fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes") fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)") @@ -241,20 +245,21 @@ func envColorMode() string { func (s *EnvSettings) EnvVars() map[string]string { envvars := map[string]string{ - "HELM_BIN": os.Args[0], - "HELM_CACHE_HOME": helmpath.CachePath(""), - "HELM_CONFIG_HOME": helmpath.ConfigPath(""), - "HELM_DATA_HOME": helmpath.DataPath(""), - "HELM_DEBUG": fmt.Sprint(s.Debug), - "HELM_PLUGINS": s.PluginsDirectory, - "HELM_REGISTRY_CONFIG": s.RegistryConfig, - "HELM_REPOSITORY_CACHE": s.RepositoryCache, - "HELM_CONTENT_CACHE": s.ContentCache, - "HELM_REPOSITORY_CONFIG": s.RepositoryConfig, - "HELM_NAMESPACE": s.Namespace(), - "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), - "HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit), - "HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32), + "HELM_BIN": os.Args[0], + "HELM_CACHE_HOME": helmpath.CachePath(""), + "HELM_CONFIG_HOME": helmpath.ConfigPath(""), + "HELM_DATA_HOME": helmpath.DataPath(""), + "HELM_DEBUG": fmt.Sprint(s.Debug), + "HELM_PLUGINS": s.PluginsDirectory, + "HELM_REGISTRY_CONFIG": s.RegistryConfig, + "HELM_REGISTRY_ALIAS_CONFIG": s.RegistryAliasConfig, + "HELM_REPOSITORY_CACHE": s.RepositoryCache, + "HELM_CONTENT_CACHE": s.ContentCache, + "HELM_REPOSITORY_CONFIG": s.RepositoryConfig, + "HELM_NAMESPACE": s.Namespace(), + "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), + "HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit), + "HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32), // broken, these are populated from helm flags and not kubeconfig. "HELM_KUBECONTEXT": s.KubeContext, diff --git a/pkg/cmd/alias.go b/pkg/cmd/alias.go new file mode 100644 index 000000000..49aa92f3d --- /dev/null +++ b/pkg/cmd/alias.go @@ -0,0 +1,42 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" +) + +const aliasHelp = ` +This command consists of multiple subcommands to interact with OCI aliases. +` + +func newAliasCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "manage OCI aliases and substitutions", + Long: aliasHelp, + } + cmd.AddCommand( + newAliasListCmd(cfg, out), + newAliasSetCmd(cfg, out), + newAliasSubstituteCmd(cfg, out), + ) + return cmd +} diff --git a/pkg/cmd/alias_list.go b/pkg/cmd/alias_list.go new file mode 100644 index 000000000..69920cb06 --- /dev/null +++ b/pkg/cmd/alias_list.go @@ -0,0 +1,75 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "io" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cli/output" + "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/registry" +) + +const aliasListDesc = ` +List registry aliases and substitutions. +` + +func newAliasListCmd(_ *action.Configuration, out io.Writer) *cobra.Command { + var aliasesOpt, substitutionsOpt bool + + cmd := &cobra.Command{ + Use: "list", + Short: "list aliases and substitutions", + Long: aliasListDesc, + Args: require.NoArgs, + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(_ *cobra.Command, _ []string) error { + var err error + a, _ := registry.LoadAliasesFile(settings.RegistryAliasConfig) + + if aliasesOpt || !substitutionsOpt { + table := uitable.New() + table.AddRow("ALIAS", "URL") + for a, url := range a.Aliases { + table.AddRow(a, url) + } + err = output.EncodeTable(out, table) + } + + if substitutionsOpt || !aliasesOpt { + table := uitable.New() + table.AddRow("SUBSTITUTION", "REPLACEMENT") + for s, r := range a.Substitutions { + table.AddRow(s, r) + } + err = output.EncodeTable(out, table) + } + + return err + }, + } + + f := cmd.Flags() + f.BoolVarP(&aliasesOpt, "aliases", "a", false, "list aliases") + f.BoolVarP(&substitutionsOpt, "substitutions", "s", false, "list substitutions") + + return cmd +} diff --git a/pkg/cmd/alias_set.go b/pkg/cmd/alias_set.go new file mode 100644 index 000000000..72ac3326e --- /dev/null +++ b/pkg/cmd/alias_set.go @@ -0,0 +1,79 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/registry" +) + +const aliasSetDesc = ` +Set or remove an alias for an OCI registry. +` + +func newAliasSetCmd(_ *action.Configuration, _ io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "set NAME [URL]", + Short: "configure the named alias", + Long: aliasSetDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(_ *cobra.Command, args []string) error { + alias := args[0] + var value *string + if len(args) > 1 { + value = &args[1] + } + + err := setAlias(settings.RegistryAliasConfig, alias, value) + + return err + }, + } + + return cmd +} + +func setAlias(aliasesFile, alias string, value *string) error { + if strings.Contains(alias, "/") { + return fmt.Errorf("alias name (%s) contains '/', please specify a different name without '/'", alias) + } + + a, err := registry.LoadAliasesFile(aliasesFile) + if err != nil && !isNotExist(err) { + return fmt.Errorf("failed to load aliases: %w", err) + } + + if value != nil { + a.SetAlias(alias, *value) + } else { + a.RemoveAlias(alias) + } + + if err := a.WriteAliasesFile(aliasesFile, 0o644); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/alias_substitute.go b/pkg/cmd/alias_substitute.go new file mode 100644 index 000000000..c67080c97 --- /dev/null +++ b/pkg/cmd/alias_substitute.go @@ -0,0 +1,74 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/registry" +) + +const aliasSubstituteDesc = ` +Set or remove a registry substitution. +` + +func newAliasSubstituteCmd(_ *action.Configuration, _ io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "substitute URL [URL]", + Short: "configure a OCI registry URL substitution", + Long: aliasSubstituteDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(_ *cobra.Command, args []string) error { + substitution := args[0] + var replacement *string + if len(args) > 1 { + replacement = &args[1] + } + + err := setSubstitution(settings.RegistryAliasConfig, substitution, replacement) + + return err + }, + } + + return cmd +} + +func setSubstitution(aliasesFile, substitution string, replacement *string) error { + a, err := registry.LoadAliasesFile(aliasesFile) + if err != nil && !isNotExist(err) { + return fmt.Errorf("failed to load aliases: %w", err) + } + + if replacement != nil { + a.SetSubstitution(substitution, *replacement) + } else { + a.RemoveSubstitution(substitution) + } + + if err := a.WriteAliasesFile(aliasesFile, 0o644); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index 320fe12ae..a8bc79336 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -61,16 +61,17 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { } man := &downloader.Manager{ - Out: out, - ChartPath: chartpath, - Keyring: client.Keyring, - SkipUpdate: client.SkipRefresh, - Getters: getter.All(settings), - RegistryClient: registryClient, - RepositoryConfig: settings.RepositoryConfig, - RepositoryCache: settings.RepositoryCache, - ContentCache: settings.ContentCache, - Debug: settings.Debug, + Out: out, + ChartPath: chartpath, + Keyring: client.Keyring, + SkipUpdate: client.SkipRefresh, + Getters: getter.All(settings), + RegistryClient: registryClient, + RegistryAliasConfig: settings.RegistryAliasConfig, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, + Debug: settings.Debug, } if client.Verify { man.Verify = downloader.VerifyIfPossible diff --git a/pkg/cmd/dependency_update.go b/pkg/cmd/dependency_update.go index b534fb48a..64bffc17b 100644 --- a/pkg/cmd/dependency_update.go +++ b/pkg/cmd/dependency_update.go @@ -65,16 +65,17 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma } man := &downloader.Manager{ - Out: out, - ChartPath: chartpath, - Keyring: client.Keyring, - SkipUpdate: client.SkipRefresh, - Getters: getter.All(settings), - RegistryClient: registryClient, - RepositoryConfig: settings.RepositoryConfig, - RepositoryCache: settings.RepositoryCache, - ContentCache: settings.ContentCache, - Debug: settings.Debug, + Out: out, + ChartPath: chartpath, + Keyring: client.Keyring, + SkipUpdate: client.SkipRefresh, + Getters: getter.All(settings), + RegistryClient: registryClient, + RegistryAliasConfig: settings.RegistryAliasConfig, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, + Debug: settings.Debug, } if client.Verify { man.Verify = downloader.VerifyAlways diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index c4e121c1f..27c10025f 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -285,16 +285,17 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options if err := action.CheckDependencies(chartRequested, req); err != nil { if client.DependencyUpdate { man := &downloader.Manager{ - Out: out, - ChartPath: cp, - Keyring: client.Keyring, - SkipUpdate: false, - Getters: p, - RepositoryConfig: settings.RepositoryConfig, - RepositoryCache: settings.RepositoryCache, - ContentCache: settings.ContentCache, - Debug: settings.Debug, - RegistryClient: client.GetRegistryClient(), + Out: out, + ChartPath: cp, + Keyring: client.Keyring, + SkipUpdate: false, + Getters: p, + RegistryAliasConfig: settings.RegistryAliasConfig, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, + Debug: settings.Debug, + RegistryClient: client.GetRegistryClient(), } if err := man.Update(); err != nil { return nil, err diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index fc56e936a..c1308ce3f 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -92,15 +92,16 @@ func newPackageCmd(out io.Writer) *cobra.Command { if client.DependencyUpdate { downloadManager := &downloader.Manager{ - Out: io.Discard, - ChartPath: path, - Keyring: client.Keyring, - Getters: p, - Debug: settings.Debug, - RegistryClient: registryClient, - RepositoryConfig: settings.RepositoryConfig, - RepositoryCache: settings.RepositoryCache, - ContentCache: settings.ContentCache, + Out: io.Discard, + ChartPath: path, + Keyring: client.Keyring, + Getters: p, + Debug: settings.Debug, + RegistryClient: registryClient, + RegistryAliasConfig: settings.RegistryAliasConfig, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, } if err := downloadManager.Update(); err != nil { diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 4f1be88d6..abcde173b 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -287,6 +287,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg ) cmd.AddCommand( + newAliasCmd(actionConfig, out), newRegistryCmd(actionConfig, out), newPushCmd(actionConfig, out), ) diff --git a/pkg/cmd/testdata/output/env-comp.txt b/pkg/cmd/testdata/output/env-comp.txt index 9d38ee464..526c46e18 100644 --- a/pkg/cmd/testdata/output/env-comp.txt +++ b/pkg/cmd/testdata/output/env-comp.txt @@ -17,6 +17,7 @@ HELM_MAX_HISTORY HELM_NAMESPACE HELM_PLUGINS HELM_QPS +HELM_REGISTRY_ALIAS_CONFIG HELM_REGISTRY_CONFIG HELM_REPOSITORY_CACHE HELM_REPOSITORY_CONFIG diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index c8fbf8bd3..82e30507d 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -203,15 +203,16 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { err = fmt.Errorf("an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: %w", err) if client.DependencyUpdate { man := &downloader.Manager{ - Out: out, - ChartPath: chartPath, - Keyring: client.Keyring, - SkipUpdate: false, - Getters: p, - RepositoryConfig: settings.RepositoryConfig, - RepositoryCache: settings.RepositoryCache, - ContentCache: settings.ContentCache, - Debug: settings.Debug, + Out: out, + ChartPath: chartPath, + Keyring: client.Keyring, + SkipUpdate: false, + Getters: p, + RegistryAliasConfig: settings.RegistryAliasConfig, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, + Debug: settings.Debug, } if err := man.Update(); err != nil { return err diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index d41b8fdb4..5ce3fba7c 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -71,10 +71,11 @@ type Manager struct { // SkipUpdate indicates that the repository should not be updated first. SkipUpdate bool // Getter collection for the operation - Getters []getter.Provider - RegistryClient *registry.Client - RepositoryConfig string - RepositoryCache string + Getters []getter.Provider + RegistryClient *registry.Client + RegistryAliasConfig string + RepositoryConfig string + RepositoryCache string // ContentCache is a location where a cache of charts can be stored ContentCache string @@ -564,11 +565,20 @@ func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, rf, err := loadRepoConfig(m.RepositoryConfig) if err != nil { if errors.Is(err, stdfs.ErrNotExist) { - return make(map[string]string), nil + rf = repo.NewFile() + } else { + return nil, err + } + } + + aliases, err := registry.LoadAliasesFile(m.RegistryAliasConfig) + if err != nil { + if errors.Is(err, stdfs.ErrNotExist) { + aliases = registry.NewAliasesFile() + } else { + return nil, err } - return nil, err } - repos := rf.Repositories reposMap := make(map[string]string) @@ -593,6 +603,8 @@ func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, continue } + dd.Repository = aliases.Expand(dd.Repository) + if registry.IsOCI(dd.Repository) { reposMap[dd.Name] = dd.Repository continue @@ -600,7 +612,7 @@ func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, found := false - for _, repo := range repos { + for _, repo := range rf.Repositories { if (strings.HasPrefix(dd.Repository, "@") && strings.TrimPrefix(dd.Repository, "@") == repo.Name) || (strings.HasPrefix(dd.Repository, "alias:") && strings.TrimPrefix(dd.Repository, "alias:") == repo.Name) { found = true diff --git a/pkg/registry/alias.go b/pkg/registry/alias.go new file mode 100644 index 000000000..2f9fadcb5 --- /dev/null +++ b/pkg/registry/alias.go @@ -0,0 +1,153 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "sigs.k8s.io/yaml" +) + +// Aliases represents the registry/aliases.yaml file +type Aliases struct { + APIVersion string `json:"apiVersion"` + Aliases map[string]string `json:"aliases"` + Substitutions map[string]string `json:"substitutions"` +} + +// NewAliasesFile generates an empty aliases file. +// +// APIVersion is automatically set. +func NewAliasesFile() *Aliases { + return &Aliases{ + APIVersion: APIVersionV1, + Aliases: map[string]string{}, + Substitutions: map[string]string{}, + } +} + +// LoadAliasesFile takes a file at the given path and returns an Aliases object +func LoadAliasesFile(path string) (*Aliases, error) { + a := NewAliasesFile() + b, err := os.ReadFile(path) + if err != nil { + return a, fmt.Errorf("couldn't load aliases file (%s): %w", path, err) + } + + err = yaml.Unmarshal(b, a) + return a, err +} + +// SetAlias adds or updates an alias. +func (a *Aliases) SetAlias(alias, url string) { + a.Aliases[alias] = url +} + +// RemoveAlias removes the entry from the list of repository aliases. +// RemoveAlias returns true if the alias existed before it was deleted. +func (a *Aliases) RemoveAlias(alias string) bool { + _, existing := a.Aliases[alias] + delete(a.Aliases, alias) + + return existing +} + +// SetSubstitution adds or updates a substitution. +func (a *Aliases) SetSubstitution(substitution, replacement string) { + a.Substitutions[substitution] = replacement +} + +// RemoveSubstitution removes the substitution and returns true if the +// substitution existed before it was deleted. +func (a *Aliases) RemoveSubstitution(substitution string) bool { + _, existing := a.Substitutions[substitution] + delete(a.Substitutions, substitution) + + return existing +} + +// Expand first expands aliases to their mapped value end then performs +// prefix substitutions until no substitution matches or each substitution +// was used at most once. +func (a *Aliases) Expand(source string) string { + return a.performSubstitutions(a.expandAlias(source)) +} + +func (a *Aliases) expandAlias(source string) string { + isAtAlias := strings.HasPrefix(source, "@") + isLongAlias := strings.HasPrefix(source, "alias:") + if isAtAlias || isLongAlias { + var alias string + if isAtAlias { + alias = strings.TrimPrefix(source, "@") + } else if isLongAlias { + alias = strings.TrimPrefix(source, "alias:") + } + if v, existing := a.Aliases[alias]; existing { + return v + } + } + + return source +} + +func (a *Aliases) performSubstitutions(source string) string { + current := source + + // no recursions + used := make(map[string]bool, len(a.Substitutions)) + orderedSubstitutions := make([]string, 0, len(a.Substitutions)) + for k := range a.Substitutions { + orderedSubstitutions = append(orderedSubstitutions, k) + } + sort.SliceStable(orderedSubstitutions, func(i, j int) bool { + return len(orderedSubstitutions[i]) < len(orderedSubstitutions[j]) + }) + var changed bool + for { + changed = false + for i := range orderedSubstitutions { + k := orderedSubstitutions[i] + if !used[k] && strings.HasPrefix(current, k) { + used[k] = true + current = a.Substitutions[k] + strings.TrimPrefix(current, k) + changed = true + } + } + if !changed { + break + } + } + + return current +} + +// WriteAliasesFile writes an aliases file to the given path. +func (a *Aliases) WriteAliasesFile(path string, perm os.FileMode) error { + data, err := yaml.Marshal(a) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, perm) +} diff --git a/pkg/registry/alias_test.go b/pkg/registry/alias_test.go new file mode 100644 index 000000000..5b439fabb --- /dev/null +++ b/pkg/registry/alias_test.go @@ -0,0 +1,176 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "os" + "reflect" + "strings" + "testing" +) + +const testAliasesFile = "testdata/aliases.yaml" + +func TestAliasesFile(t *testing.T) { + a := NewAliasesFile() + a.SetAlias("staging", "oci://example.com/charts/staging") + a.SetAlias("production", "oci://example.com/charts/production") + + a.SetSubstitution("oci://example.com/charts/production", "oci://example.com/qa-environment/charts/production") + + if len(a.Aliases) != 2 { + t.Fatal("Expected 2 aliases") + } + + if len(a.Substitutions) != 1 { + t.Fatal("Expected 1 substitution") + } + + if !a.RemoveAlias("staging") { + t.Fatal("Expected staging alias to exist") + } + + if a.RemoveAlias("staging") { + t.Fatal("Expected staging alias to not exist") + } + + if len(a.Aliases) != 1 { + t.Fatal("Expected 1 alias") + } + + if !a.RemoveSubstitution("oci://example.com/charts/production") { + t.Fatal("Expected 'oci://example.com/charts/production' substitution to exist") + } + + if a.RemoveSubstitution("oci://example.com/charts/production") { + t.Fatal("Expected 'oci://example.com/charts/production' substitution to not exist") + } + + if len(a.Substitutions) != 0 { + t.Fatal("Expected 0 substitutions") + } +} + +func TestNewAliasesFile(t *testing.T) { + expects := NewAliasesFile() + expects.SetAlias("staging", "oci://example.com/charts/staging") + expects.SetAlias("production", "oci://example.com/charts/production") + expects.SetAlias("dev", "oci://example.com/charts/dev") + expects.SetSubstitution("oci://example.com/charts/dev", "oci://dev.example.com/charts") + expects.SetSubstitution("oci://example.com/charts/staging", "oci://staging.example.com/charts") + expects.SetSubstitution("https://example.com/stable/charts", "oci://stable.example.com/charts") + + file, err := LoadAliasesFile(testAliasesFile) + if err != nil { + t.Errorf("%q could not be loaded: %s", testAliasesFile, err) + } + + if !reflect.DeepEqual(expects.APIVersion, file.APIVersion) { + t.Fatalf("Unexpected apiVersion: %#v", file.APIVersion) + } + + if !reflect.DeepEqual(expects.Aliases, file.Aliases) { + t.Fatalf("Unexpected aliases: %#v", file.Aliases) + } + + if !reflect.DeepEqual(expects.Substitutions, file.Substitutions) { + t.Fatalf("Unexpected substitutions: %#v", file.Substitutions) + } +} + +func TestWriteAliasesFile(t *testing.T) { + expects := NewAliasesFile() + expects.SetAlias("dev", "oci://example.com/charts/dev") + expects.SetSubstitution("oci://example.com/charts/dev", "oci://dev.example.com/charts") + + file, err := os.CreateTemp(t.TempDir(), "helm-aliases") + if err != nil { + t.Errorf("failed to create test-file (%v)", err) + } + defer os.Remove(file.Name()) + if err := expects.WriteAliasesFile(file.Name(), 0o644); err != nil { + t.Errorf("failed to write file (%v)", err) + } + + aliases, err := LoadAliasesFile(file.Name()) + if err != nil { + t.Errorf("failed to load file (%v)", err) + } + + if !reflect.DeepEqual(expects, aliases) { + t.Errorf("aliases inconsistent after saving and reloading:\nexpected: %#v\nactual: %#v", expects, aliases) + } +} + +func TestAliasNotExists(t *testing.T) { + if _, err := LoadAliasesFile("/this/path/does/not/exist.yaml"); err == nil { + t.Errorf("expected err to be non-nil when path does not exist") + } else if !strings.Contains(err.Error(), "couldn't load aliases file") { + t.Errorf("expected prompt `couldn't load aliases file`") + } +} + +func TestAliases_performSubstitutions(t *testing.T) { + substitutions := NewAliasesFile() + substitutions.SetSubstitution("oci://example.com/charts", "oci://example.com/charts/dev") + substitutions.SetSubstitution("oci://length.example.com", "oci://shorter.length.example.com") + substitutions.SetSubstitution("oci://length.example.com/charts", "oci://longer.length.example.com/charts") + substitutions.SetSubstitution("oci://multiple.example.com", "oci://example.com/charts") + substitutions.SetSubstitution("oci://localhost:5000/", "oci://staging.example.com/charts/") + substitutions.SetSubstitution("https://example.com/vendor", "oci://vendor.example.com/charts/") + substitutions.SetSubstitution("oci://one.example.com", "oci://two.example.com") + substitutions.SetSubstitution("oci://two.example.com", "oci://one.example.com") + + tests := []struct { + name string + source string + want string + }{ + { + name: "basicOCIReplacement", + source: "oci://localhost:5000/myrepo", + want: "oci://staging.example.com/charts/myrepo", + }, + { + name: "exacltyAsRequested", + source: "https://example.com/vendor-dev/some-chart-repo", + want: "oci://vendor.example.com/charts/-dev/some-chart-repo", + }, + { + name: "multipleReplacements", + source: "oci://multiple.example.com/myrepo", + want: "oci://example.com/charts/dev/myrepo", + }, + { + name: "norecursion", + source: "oci://one.example.com/myrepo", + want: "oci://one.example.com/myrepo", + }, + { + name: "usedOnlyOnce", + source: "oci://example.com/charts/myrepo", + want: "oci://example.com/charts/dev/myrepo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := substitutions.performSubstitutions(tt.source); got != tt.want { + t.Errorf("Aliases.performSubstitutions() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/registry/constants.go b/pkg/registry/constants.go index c455cf314..1fd45350c 100644 --- a/pkg/registry/constants.go +++ b/pkg/registry/constants.go @@ -34,4 +34,7 @@ const ( // LegacyChartLayerMediaType is the legacy reserved media type for Helm chart package content. LegacyChartLayerMediaType = "application/tar+gzip" + + // APIVersionV1 is the v1 API version for the aliases and substitutions file. + APIVersionV1 = "v1" ) diff --git a/pkg/registry/testdata/aliases.yaml b/pkg/registry/testdata/aliases.yaml new file mode 100644 index 000000000..2acecf154 --- /dev/null +++ b/pkg/registry/testdata/aliases.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +aliases: + staging: oci://example.com/charts/staging + production: oci://example.com/charts/production + dev: oci://example.com/charts/dev +substitutions: + oci://example.com/charts/dev: oci://dev.example.com/charts + oci://example.com/charts/staging: oci://staging.example.com/charts + https://example.com/stable/charts: oci://stable.example.com/charts