Skip to content

Commit

Permalink
fix(helm): handle OCI version constraints and metadata (akuity#2837)
Browse files Browse the repository at this point in the history
Signed-off-by: Hidde Beydals <hidde@hhh.computer>
  • Loading branch information
hiddeco authored Oct 25, 2024
1 parent 7b265b1 commit c462c76
Show file tree
Hide file tree
Showing 2 changed files with 40 additions and 111 deletions.
123 changes: 22 additions & 101 deletions internal/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"slices"
"strings"

Expand All @@ -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
Expand All @@ -43,13 +39,15 @@ func DiscoverChartVersions(
semverConstraint string,
creds *Credentials,
) ([]string, error) {
var isOCI bool
var versions []string
var err error
switch {
case strings.HasPrefix(repoURL, "http://"), strings.HasPrefix(repoURL, "https://"):
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)
}
Expand All @@ -62,7 +60,7 @@ func DiscoverChartVersions(
)
}

semvers := versionsToSemVerCollection(versions)
semvers := versionsToSemVerCollection(versions, isOCI)
if len(semvers) == 0 {
return nil, nil
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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.
Expand All @@ -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),
}
}
28 changes: 18 additions & 10 deletions internal/helm/helm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func TestVersionsToSemVerCollection(t *testing.T) {
testCases := []struct {
name string
input []string
isOCI bool
expected semver.Collection
}{
{
Expand Down Expand Up @@ -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)
})
}
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit c462c76

Please sign in to comment.