From c462c76edfd53821dbf7d5ec8aca24ed1fca6cf1 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 25 Oct 2024 14:35:31 +0200 Subject: [PATCH] fix(helm): handle OCI version constraints and metadata (#2837) Signed-off-by: Hidde Beydals --- internal/helm/helm.go | 123 +++++++------------------------------ internal/helm/helm_test.go | 28 ++++++--- 2 files changed, 40 insertions(+), 111 deletions(-) diff --git a/internal/helm/helm.go b/internal/helm/helm.go index d1fe815f5..8b3c23bb5 100644 --- a/internal/helm/helm.go +++ b/internal/helm/helm.go @@ -5,8 +5,6 @@ import ( "fmt" "io" "net/http" - "os" - "os/exec" "slices" "strings" @@ -15,8 +13,6 @@ import ( "oras.land/oras-go/pkg/registry" "oras.land/oras-go/pkg/registry/remote" "oras.land/oras-go/pkg/registry/remote/auth" - - libExec "github.com/akuity/kargo/internal/exec" ) // DiscoverChartVersions connects to the specified Helm chart repository and @@ -43,6 +39,7 @@ func DiscoverChartVersions( semverConstraint string, creds *Credentials, ) ([]string, error) { + var isOCI bool var versions []string var err error switch { @@ -50,6 +47,7 @@ func DiscoverChartVersions( versions, err = getChartVersionsFromClassicRepo(repoURL, chart, creds) case strings.HasPrefix(repoURL, "oci://"): versions, err = getChartVersionsFromOCIRepo(ctx, repoURL, creds) + isOCI = true default: return nil, fmt.Errorf("repository URL %q is invalid", repoURL) } @@ -62,7 +60,7 @@ func DiscoverChartVersions( ) } - semvers := versionsToSemVerCollection(versions) + semvers := versionsToSemVerCollection(versions, isOCI) if len(semvers) == 0 { return nil, nil } @@ -185,10 +183,25 @@ func getChartVersionsFromOCIRepo( // versionsToSemVerCollection converts a slice of versions to a semver.Collection. // Any versions that cannot be parsed as SemVer are ignored. -func versionsToSemVerCollection(versions []string) semver.Collection { +func versionsToSemVerCollection(versions []string, isOCI bool) semver.Collection { + newSemver := semver.NewVersion + if isOCI { + // OCI artifact tags produced by Helm are STRICT SemVer, meaning that + // they must contain a patch version and do not start with a "v". + // I.e., "1.0.0" is valid, but "v1.0" is not. This is enforced by Helm + // itself when publishing charts. + newSemver = semver.StrictNewVersion + } + semvers := make(semver.Collection, 0, len(versions)) for _, version := range versions { - semverVersion, err := semver.NewVersion(version) + // OCI artifact tags are not allowed to contain the "+" character, + // which is used by SemVer to separate the version from the build + // metadata. To work around this, Helm uses "_" instead of "+". + if isOCI { + version = strings.ReplaceAll(version, "_", "+") + } + semverVersion, err := newSemver(version) if err == nil { semvers = append(semvers, semverVersion) } @@ -201,7 +214,8 @@ func versionsToSemVerCollection(versions []string) semver.Collection { func semVerCollectionToVersions(semvers semver.Collection) []string { versions := make([]string, len(semvers)) for i, semverVersion := range semvers { - versions[i] = semverVersion.Original() + original := semverVersion.Original() + versions[i] = original } return versions } @@ -223,84 +237,6 @@ func filterSemVers(semvers semver.Collection, semverConstraint string) (semver.C return filtered, nil } -// Login runs `helm registry login` or `helm repo add` for the provided -// repository. The provided homePath is used to set the HOME environment -// variable, as well as the XDG_* environment variables. This ensures that Helm -// uses the provided homePath as its configuration directory, and allows for -// isolation. -func Login(homePath, repository string, credentials Credentials) error { - var args []string - switch { - case strings.HasPrefix(repository, "oci://"): - // When logging into an OCI registry, both username and password are - // required. If the password is missing, return an error as otherwise - // it would prompt the user for it. - if credentials.Username == "" || credentials.Password == "" { - return fmt.Errorf("missing username and/or password for OCI registry login") - } - - // NB: Registry login works _without_ the oci:// prefix. - args = append(args, "registry", "login", NormalizeChartRepositoryURL(repository)) - case strings.HasPrefix(repository, "https://"): - // When logging into an HTTPS repository, a password is required if a - // username is provided. If the password is missing, return an error as - // otherwise it would prompt the user for it. - if credentials.Username != "" && credentials.Password == "" { - return fmt.Errorf("missing password for HTTPS repository login") - } - - // NB: The repository "alias" does not accept slashes, but does accept - // any other type of character. - args = append(args, "repo", "add", strings.ReplaceAll(repository, "/", ""), repository) - default: - return fmt.Errorf("unsupported repository URL %q", repository) - } - - // Flags for username and password are the same for both `helm registry login` - // and `helm repo add`. - if credentials.Username != "" { - args = append(args, "--username", credentials.Username) - } - if credentials.Password != "" { - args = append(args, "--password-stdin") - } - - cmd := exec.Command("helm", args...) - cmd.Env = append(cmd.Env, os.Environ()...) - cmd.Env = append(cmd.Env, helmEnv(homePath)...) - - // If a password is provided, write it to the command's stdin. - if credentials.Password != "" { - in, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("stdin pipe for password: %w", err) - } - go func() { - defer in.Close() - _, _ = io.WriteString(in, credentials.Password) - }() - } - - if _, err := libExec.Exec(cmd); err != nil { - return err - } - return nil -} - -// UpdateChartDependencies runs `helm dependency update` for the chart at the -// provided chartPath. The homePath is used to set the HOME environment variable, -// as well as the XDG_* environment variables. This ensures that Helm uses the -// provided homePath as its configuration directory, and allows for isolation. -func UpdateChartDependencies(homePath, chartPath string) error { - cmd := exec.Command("helm", "dependency", "update", chartPath) - cmd.Env = append(cmd.Env, os.Environ()...) - cmd.Env = append(cmd.Env, helmEnv(homePath)...) - if _, err := libExec.Exec(cmd); err != nil { - return err - } - return nil -} - // NormalizeChartRepositoryURL normalizes a chart repository URL for purposes // of comparison. Crucially, this function removes the oci:// prefix from the // URL if there is one. @@ -312,18 +248,3 @@ func NormalizeChartRepositoryURL(repo string) string { "oci://", ) } - -// helmEnv returns a slice of environment variables that should be set when -// running Helm commands. The provided homePath is used to set the HOME -// environment variable, as well as the XDG_* environment variables. -// -// This ensures that Helm uses the provided homePath as its configuration -// directory. -func helmEnv(homePath string) []string { - return []string{ - fmt.Sprintf("HOME=%s", homePath), - fmt.Sprintf("XDG_CACHE_HOME=%s/cache", homePath), - fmt.Sprintf("XDG_CONFIG_HOME=%s/config", homePath), - fmt.Sprintf("XDG_DATA_HOME=%s/data", homePath), - } -} diff --git a/internal/helm/helm_test.go b/internal/helm/helm_test.go index 06cd5a98b..974cf21ae 100644 --- a/internal/helm/helm_test.go +++ b/internal/helm/helm_test.go @@ -108,6 +108,7 @@ func TestVersionsToSemVerCollection(t *testing.T) { testCases := []struct { name string input []string + isOCI bool expected semver.Collection }{ { @@ -151,11 +152,27 @@ func TestVersionsToSemVerCollection(t *testing.T) { semver.MustParse("7.8.9+build.3"), }, }, + { + name: "metadata versions from OCI origin", + input: []string{"1.2.3_build.1", "4.5.6_build.2", "7.8.9_build.3"}, + isOCI: true, + expected: semver.Collection{ + semver.MustParse("1.2.3+build.1"), + semver.MustParse("4.5.6+build.2"), + semver.MustParse("7.8.9+build.3"), + }, + }, + { + name: "loose versions from OCI origin", + input: []string{"v1.2.3", "v4.5.6", "v7.8.9"}, + isOCI: true, + expected: semver.Collection{}, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - actual := versionsToSemVerCollection(tc.input) + actual := versionsToSemVerCollection(tc.input, tc.isOCI) assert.Equal(t, tc.expected, actual) }) } @@ -199,15 +216,6 @@ func TestSemVerCollectionToVersions(t *testing.T) { }, expected: []string{"1.2.3+build.1", "4.5.6+build.2", "7.8.9+build.3"}, }, - { - name: "loose versions", - input: semver.Collection{ - semver.MustParse("v1.2.3"), - semver.MustParse("v4.5.6"), - semver.MustParse("v7.8.9"), - }, - expected: []string{"v1.2.3", "v4.5.6", "v7.8.9"}, - }, } for _, tc := range testCases {