From 5c43fa6465745ce1002fae9fc7067f52331ae2eb Mon Sep 17 00:00:00 2001 From: Aditya Menon Date: Thu, 12 Feb 2026 17:50:43 +0530 Subject: [PATCH] fix: support OCI chart digest syntax (@sha256:...) (#2398) fix: support OCI chart digest syntax in chart URLs and version fields Helm supports pinning OCI chart images by digest (@sha256:...), version tag (:version), or both (:version@sha256:digest) since helm/helm#12690. Helmfile failed to parse these formats, incorrectly constructing helm commands and losing version/digest information embedded in chart URLs. Root causes: - resolveOciChart() used last ":" to find version tag, but sha256:abc contains ":", so digest URLs were split incorrectly - getOCIQualifiedChartName() included :version and @digest in chartName with no parsing of either source - appendChartVersionFlags() passed release.Version verbatim to --version flag, including any digest suffix - ChartPull() discarded the tag from resolveOciChart but did not preserve digest in the URL This commit adds parseOCIChartRef() and parseVersionDigest() utilities, then updates the OCI chart handling pipeline so that: - Digests are preserved in the chart URL passed to helm pull - Version tags are extracted cleanly for the --version flag - Both chart URL and version field are parsed for version/digest info Fixes #2097 Signed-off-by: Aditya Menon --- pkg/helmexec/exec.go | 12 +- pkg/helmexec/exec_test.go | 24 ++ pkg/state/oci_chart_version_test.go | 45 +++ pkg/state/oci_parse_test.go | 118 ++++++ pkg/state/state.go | 121 +++++- pkg/state/state_test.go | 206 ++++++++++ test/integration/run.sh | 1 + test/integration/test-cases/issue-2097.sh | 363 ++++++++++++++++++ .../test-cases/issue-2097/chart/Chart.yaml | 6 + .../issue-2097/chart/templates/configmap.yaml | 8 + .../test-cases/issue-2097/chart/values.yaml | 3 + .../input/helmfile-digest-in-url.yaml | 4 + .../input/helmfile-digest-in-version.yaml | 5 + .../input/helmfile-version-in-url.yaml | 4 + 14 files changed, 910 insertions(+), 10 deletions(-) create mode 100644 pkg/state/oci_parse_test.go create mode 100644 test/integration/test-cases/issue-2097.sh create mode 100644 test/integration/test-cases/issue-2097/chart/Chart.yaml create mode 100644 test/integration/test-cases/issue-2097/chart/templates/configmap.yaml create mode 100644 test/integration/test-cases/issue-2097/chart/values.yaml create mode 100644 test/integration/test-cases/issue-2097/input/helmfile-digest-in-url.yaml create mode 100644 test/integration/test-cases/issue-2097/input/helmfile-digest-in-version.yaml create mode 100644 test/integration/test-cases/issue-2097/input/helmfile-version-in-url.yaml diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 08f1d5d3..85507636 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -962,8 +962,16 @@ func (helm *execer) IsVersionAtLeast(versionStr string) bool { } func resolveOciChart(ociChart string) (ociChartURL, ociChartTag string) { + // Split off digest (e.g., @sha256:abc) first so the colon in sha256: + // does not confuse the version tag search below. + var digest string + if atIdx := strings.Index(ociChart, "@"); atIdx >= 0 { + digest = ociChart[atIdx:] // includes the "@" + ociChart = ociChart[:atIdx] + } + var urlTagIndex int - // Get the last : index + // Get the last : index in the pre-digest part // e.g., // 1. registry:443/helm-charts // 2. registry/helm-charts:latest @@ -975,7 +983,7 @@ func resolveOciChart(ociChart string) (ociChartURL, ociChartTag string) { urlTagIndex = strings.LastIndex(ociChart, ":") ociChartTag = ociChart[urlTagIndex+1:] } - ociChartURL = fmt.Sprintf("oci://%s", ociChart[:urlTagIndex]) + ociChartURL = fmt.Sprintf("oci://%s%s", ociChart[:urlTagIndex], digest) return ociChartURL, ociChartTag } diff --git a/pkg/helmexec/exec_test.go b/pkg/helmexec/exec_test.go index cea2c966..66ab9890 100644 --- a/pkg/helmexec/exec_test.go +++ b/pkg/helmexec/exec_test.go @@ -1381,6 +1381,30 @@ func Test_resolveOciChart(t *testing.T) { ociChartURL: "oci://chart:5000/nginx", ociChartTag: "", }, + { + name: "digest only", + chartPath: "ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085", + ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085", + ociChartTag: "", + }, + { + name: "version and digest", + chartPath: "ghcr.io/nginxinc/charts/nginx-ingress:2.0.0@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085", + ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085", + ociChartTag: "2.0.0", + }, + { + name: "port with digest", + chartPath: "registry:5000/chart@sha256:abc123", + ociChartURL: "oci://registry:5000/chart@sha256:abc123", + ociChartTag: "", + }, + { + name: "port with version and digest", + chartPath: "registry:5000/chart:1.0.0@sha256:abc123", + ociChartURL: "oci://registry:5000/chart@sha256:abc123", + ociChartTag: "1.0.0", + }, } for i := range tests { tt := tests[i] diff --git a/pkg/state/oci_chart_version_test.go b/pkg/state/oci_chart_version_test.go index dc931a94..d91fa375 100644 --- a/pkg/state/oci_chart_version_test.go +++ b/pkg/state/oci_chart_version_test.go @@ -83,6 +83,51 @@ func TestOCIChartVersionHandling(t *testing.T) { expectedError: false, expectedQualifiedChart: "", }, + { + name: "OCI chart with digest in version field", + chart: "oci://registry.example.com/my-chart", + version: "1.2.3@sha256:abc123def456", + helmVersion: "3.18.0", + expectedVersion: "1.2.3", + expectedError: false, + expectedQualifiedChart: "registry.example.com/my-chart@sha256:abc123def456", + }, + { + name: "OCI chart with digest-only in version field", + chart: "oci://registry.example.com/my-chart", + version: "@sha256:abc123def456", + helmVersion: "3.18.0", + expectedVersion: "", + expectedError: false, + expectedQualifiedChart: "registry.example.com/my-chart@sha256:abc123def456", + }, + { + name: "OCI chart with version tag in URL", + chart: "oci://registry.example.com/my-chart:1.2.3", + version: "", + helmVersion: "3.18.0", + expectedVersion: "1.2.3", + expectedError: false, + expectedQualifiedChart: "registry.example.com/my-chart", + }, + { + name: "OCI chart with digest in URL", + chart: "oci://registry.example.com/my-chart@sha256:abc123def456", + version: "", + helmVersion: "3.18.0", + expectedVersion: "", + expectedError: false, + expectedQualifiedChart: "registry.example.com/my-chart@sha256:abc123def456", + }, + { + name: "OCI chart with version and digest in URL", + chart: "oci://registry.example.com/my-chart:1.2.3@sha256:abc123def456", + version: "", + helmVersion: "3.18.0", + expectedVersion: "1.2.3", + expectedError: false, + expectedQualifiedChart: "registry.example.com/my-chart@sha256:abc123def456", + }, } for _, tt := range tests { diff --git a/pkg/state/oci_parse_test.go b/pkg/state/oci_parse_test.go new file mode 100644 index 00000000..d982673f --- /dev/null +++ b/pkg/state/oci_parse_test.go @@ -0,0 +1,118 @@ +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseOCIChartRef(t *testing.T) { + tests := []struct { + name string + chartURL string + expectedBase string + expectedVer string + expectedDigest string + }{ + { + name: "plain OCI URL", + chartURL: "oci://registry/chart", + expectedBase: "oci://registry/chart", + expectedVer: "", + expectedDigest: "", + }, + { + name: "OCI URL with version", + chartURL: "oci://registry/chart:2.0.0", + expectedBase: "oci://registry/chart", + expectedVer: "2.0.0", + expectedDigest: "", + }, + { + name: "OCI URL with digest", + chartURL: "oci://registry/chart@sha256:abc", + expectedBase: "oci://registry/chart", + expectedVer: "", + expectedDigest: "sha256:abc", + }, + { + name: "OCI URL with version and digest", + chartURL: "oci://reg/chart:2.0@sha256:abc", + expectedBase: "oci://reg/chart", + expectedVer: "2.0", + expectedDigest: "sha256:abc", + }, + { + name: "OCI URL with port, version, and digest", + chartURL: "oci://reg:5000/chart:1.0@sha256:a", + expectedBase: "oci://reg:5000/chart", + expectedVer: "1.0", + expectedDigest: "sha256:a", + }, + { + name: "OCI URL with port only", + chartURL: "oci://reg:5000/chart", + expectedBase: "oci://reg:5000/chart", + expectedVer: "", + expectedDigest: "", + }, + { + name: "OCI URL with port and digest, no version", + chartURL: "oci://reg:5000/chart@sha256:abc", + expectedBase: "oci://reg:5000/chart", + expectedVer: "", + expectedDigest: "sha256:abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base, ver, digest := parseOCIChartRef(tt.chartURL) + assert.Equal(t, tt.expectedBase, base, "baseURL mismatch") + assert.Equal(t, tt.expectedVer, ver, "version mismatch") + assert.Equal(t, tt.expectedDigest, digest, "digest mismatch") + }) + } +} + +func TestParseVersionDigest(t *testing.T) { + tests := []struct { + name string + version string + expectedVer string + expectedDigest string + }{ + { + name: "version only", + version: "2.0.0", + expectedVer: "2.0.0", + expectedDigest: "", + }, + { + name: "version with digest", + version: "2.0.0@sha256:abc", + expectedVer: "2.0.0", + expectedDigest: "sha256:abc", + }, + { + name: "digest only", + version: "@sha256:abc", + expectedVer: "", + expectedDigest: "sha256:abc", + }, + { + name: "empty string", + version: "", + expectedVer: "", + expectedDigest: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ver, digest := parseVersionDigest(tt.version) + assert.Equal(t, tt.expectedVer, ver, "version mismatch") + assert.Equal(t, tt.expectedDigest, digest, "digest mismatch") + }) + } +} diff --git a/pkg/state/state.go b/pkg/state/state.go index 53570cca..19d74e2d 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -3513,8 +3513,13 @@ func (st *HelmState) appendTakeOwnershipFlagsForDiff(flags []string, release *Re } func (st *HelmState) appendChartVersionFlags(flags []string, release *ReleaseSpec) []string { - if release.Version != "" { - flags = append(flags, "--version", release.Version) + version := release.Version + // Strip OCI digest from version (digest is handled in chart URL, not --version flag) + if idx := strings.Index(version, "@"); idx >= 0 { + version = version[:idx] + } + if version != "" { + flags = append(flags, "--version", version) } if st.isDevelopment(release) { @@ -4807,7 +4812,17 @@ func (st *HelmState) getOCIChart(release *ReleaseSpec, tempDir string, helm helm flags = st.appendVerifyFlags(flags, release) flags = st.appendKeyringFlags(flags, release) flags = st.appendChartDownloadFlags(flags, release) - flags = st.appendChartVersionFlags(flags, release) + // Use the clean chartVersion (without digest) from getOCIQualifiedChartName + // rather than appendChartVersionFlags which uses release.Version verbatim. + // The digest is already embedded in qualifiedChartName. + // When a digest is present, omit --version: the digest is the authoritative + // content identifier, and passing both causes errors in some Helm versions. + if chartVersion != "" && !strings.Contains(qualifiedChartName, "@") { + flags = append(flags, "--version", chartVersion) + } + if st.isDevelopment(release) { + flags = append(flags, "--devel") + } if err := helm.ChartPull(qualifiedChartName, chartPath, flags...); err != nil { lockResult.Release(st.logger) @@ -4850,6 +4865,48 @@ func (st *HelmState) IsOCIChart(chart string) bool { return repo.OCI } +// parseOCIChartRef parses an OCI chart URL into base URL, version tag, and digest. +// Examples: +// +// oci://registry/chart → (oci://registry/chart, "", "") +// oci://registry/chart:2.0.0 → (oci://registry/chart, "2.0.0", "") +// oci://registry/chart@sha256:a → (oci://registry/chart, "", "sha256:a") +// oci://registry/chart:2.0@sha256:a → (oci://registry/chart, "2.0", "sha256:a") +// oci://reg:5000/chart:1.0@sha256:a → (oci://reg:5000/chart, "1.0", "sha256:a") +func parseOCIChartRef(chartURL string) (baseURL, version, digest string) { + // Split off digest first (everything after @) + if atIdx := strings.Index(chartURL, "@"); atIdx >= 0 { + digest = chartURL[atIdx+1:] + chartURL = chartURL[:atIdx] + } + + // Find version tag: last ":" that comes after the last "/" + lastSlash := strings.LastIndex(chartURL, "/") + lastColon := strings.LastIndex(chartURL, ":") + if lastColon > lastSlash { + version = chartURL[lastColon+1:] + baseURL = chartURL[:lastColon] + } else { + baseURL = chartURL + } + + return baseURL, version, digest +} + +// parseVersionDigest splits a version string that may contain an OCI digest. +// Examples: +// +// "2.0.0" → ("2.0.0", "") +// "2.0.0@sha256:abc" → ("2.0.0", "sha256:abc") +// "@sha256:abc" → ("", "sha256:abc") +// "" → ("", "") +func parseVersionDigest(version string) (ver, digest string) { + if atIdx := strings.Index(version, "@"); atIdx >= 0 { + return version[:atIdx], version[atIdx+1:] + } + return version, "" +} + func (st *HelmState) getOCIQualifiedChartName(release *ReleaseSpec) (string, string, string, error) { // For issue #2247: Don't default to "latest" - use empty string to let Helm pull the latest version // Only use the version explicitly provided by the user @@ -4867,21 +4924,69 @@ func (st *HelmState) getOCIQualifiedChartName(release *ReleaseSpec) (string, str // Reject explicit "latest" for OCI charts (issue #1047, #2247) // This only applies if user explicitly specified "latest", not when version is omitted // We reject for all Helm versions to ensure consistent behavior - if release.Version == "latest" { + // Strip any digest suffix before checking (e.g. "latest@sha256:..." is still invalid) + versionForCheck, _ := parseVersionDigest(release.Version) + if versionForCheck == "latest" { return "", "", "", fmt.Errorf("the version for OCI charts should be semver compliant, the latest tag is not supported") } var qualifiedChartName, chartName string if strings.HasPrefix(release.Chart, "oci://") { - parts := strings.Split(release.Chart, "/") + // Parse version and digest from the chart URL + baseURL, versionInURL, digestInURL := parseOCIChartRef(release.Chart) + + // Parse version and digest from the version field + versionInField, digestInField := parseVersionDigest(chartVersion) + + // Merge: version field takes precedence; fall back to URL-embedded version + finalVersion := versionInField + if finalVersion == "" && versionInURL != "" { + finalVersion = versionInURL + } + + // Merge: URL-embedded digest takes precedence; fall back to version field digest + finalDigest := digestInURL + if finalDigest == "" { + finalDigest = digestInField + } + + // Extract chart name from base URL (last path segment) + parts := strings.Split(baseURL, "/") chartName = parts[len(parts)-1] - qualifiedChartName = strings.Replace(fmt.Sprintf("%s:%s", release.Chart, chartVersion), "oci://", "", 1) + + // Build qualifiedChartName: base (without oci:// prefix) + digest or version + base := strings.TrimPrefix(baseURL, "oci://") + if finalDigest != "" { + // Digest present — put it in the URL; version goes through --version flag only + qualifiedChartName = fmt.Sprintf("%s@%s", base, finalDigest) + } else if versionInURL == "" && finalVersion != "" { + // Version from field only (backward compatible format) + qualifiedChartName = fmt.Sprintf("%s:%s", base, finalVersion) + } else { + // Version came from URL (handled via --version flag) or no version at all + qualifiedChartName = base + } + + chartVersion = finalVersion } else { var repo *RepositorySpec repo, chartName = st.GetRepositoryAndNameFromChartName(release.Chart) - qualifiedChartName = fmt.Sprintf("%s/%s:%s", repo.URL, chartName, chartVersion) + + // Handle digest in version field for repo-aliased OCI charts too + base := fmt.Sprintf("%s/%s", repo.URL, chartName) + finalVersion, digest := parseVersionDigest(chartVersion) + + switch { + case digest != "": + qualifiedChartName = fmt.Sprintf("%s@%s", base, digest) + case finalVersion != "": + qualifiedChartName = fmt.Sprintf("%s:%s", base, finalVersion) + default: + qualifiedChartName = base + } + + chartVersion = finalVersion } - qualifiedChartName = strings.TrimSuffix(qualifiedChartName, ":") return qualifiedChartName, chartName, chartVersion, nil } diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index f9e8e642..a283b458 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -3701,6 +3701,212 @@ func TestGetOCIQualifiedChartName(t *testing.T) { {"registry/chart-path/chart-name", "chart-name", ""}, }, }, + // Digest in version field + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name", + Version: "2.0.0@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", "2.0.0"}, + }, + }, + // Digest-only in version field + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name", + Version: "@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", ""}, + }, + }, + // Version tag in URL + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name:2.0.0", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name", "chart-name", "2.0.0"}, + }, + }, + // Digest in URL + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", ""}, + }, + }, + // Version + digest in URL + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name:2.0.0@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", "2.0.0"}, + }, + }, + // Port with digest in URL + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry:5000/chart-path/chart-name@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry:5000/chart-path/chart-name@sha256:abc123", "chart-name", ""}, + }, + }, + // Digest in URL + version field + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{}, + Releases: []ReleaseSpec{ + { + Chart: "oci://registry/chart-path/chart-name@sha256:abc123", + Version: "2.0.0", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", "2.0.0"}, + }, + }, + // Repo-aliased OCI chart with digest in version + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{ + { + Name: "oci-repo", + URL: "registry/chart-path", + OCI: true, + }, + }, + Releases: []ReleaseSpec{ + { + Chart: "oci-repo/chart-name", + Version: "2.0.0@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", "2.0.0"}, + }, + }, + // Repo-aliased OCI chart with digest-only version + { + state: HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Repositories: []RepositorySpec{ + { + Name: "oci-repo", + URL: "registry/chart-path", + OCI: true, + }, + }, + Releases: []ReleaseSpec{ + { + Chart: "oci-repo/chart-name", + Version: "@sha256:abc123", + }, + }, + }, + }, + helmVersion: "3.13.3", + expected: []struct { + qualifiedChartName string + chartName string + chartVersion string + }{ + {"registry/chart-path/chart-name@sha256:abc123", "chart-name", ""}, + }, + }, } for _, tt := range tests { diff --git a/test/integration/run.sh b/test/integration/run.sh index a7402bf7..a6bbc955 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -117,6 +117,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes . ${dir}/test-cases/issue-2281-array-merge.sh . ${dir}/test-cases/issue-2353-layer-array-replace.sh . ${dir}/test-cases/issue-2247.sh +. ${dir}/test-cases/issue-2097.sh . ${dir}/test-cases/issue-2291.sh . ${dir}/test-cases/oci-parallel-pull.sh . ${dir}/test-cases/issue-2297-local-chart-transformers.sh diff --git a/test/integration/test-cases/issue-2097.sh b/test/integration/test-cases/issue-2097.sh new file mode 100644 index 00000000..c2cc6a6f --- /dev/null +++ b/test/integration/test-cases/issue-2097.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash + +# Test for issue #2097: OCI chart digest support +# This test combines validation tests (fast) with registry tests (comprehensive) + +issue_2097_input_dir="${cases_dir}/issue-2097/input" +issue_2097_chart_dir="${cases_dir}/issue-2097/chart" +issue_2097_tmp_dir=$(mktemp -d) + +test_start "issue-2097: OCI chart digest support" + +# ============================================================================================================== +# PART 1: Fast Validation Tests (no registry required) +# ============================================================================================================== + +info "Part 1: Validation tests (no registry required)" + +# Test 1.1: Digest in version field should NOT trigger semver validation error +info "Test 1.1: Verifying version with digest (2.0.0@sha256:...) does not trigger validation error" +set +e +${helmfile} -f "${issue_2097_input_dir}/helmfile-digest-in-version.yaml" template > "${issue_2097_tmp_dir}/digest-in-version.txt" 2>&1 +code=$? +set -e + +# The command will fail because the registry doesn't exist, but it should NOT fail with +# "semver compliant" validation error +if grep -q "semver compliant" "${issue_2097_tmp_dir}/digest-in-version.txt"; then + cat "${issue_2097_tmp_dir}/digest-in-version.txt" + rm -rf "${issue_2097_tmp_dir}" + fail "Issue #2097 regression: version with digest triggered validation error" +fi + +info "SUCCESS: version with digest does not trigger validation error" + +# Test 1.2: Version tag in chart URL should NOT trigger validation error +info "Test 1.2: Verifying chart URL with version tag (oci://reg/chart:2.0.0) does not trigger validation error" +set +e +${helmfile} -f "${issue_2097_input_dir}/helmfile-version-in-url.yaml" template > "${issue_2097_tmp_dir}/version-in-url.txt" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/version-in-url.txt"; then + cat "${issue_2097_tmp_dir}/version-in-url.txt" + rm -rf "${issue_2097_tmp_dir}" + fail "Issue #2097 regression: version in chart URL triggered validation error" +fi + +info "SUCCESS: version in chart URL does not trigger validation error" + +# Test 1.3: Digest in chart URL should NOT trigger validation error +info "Test 1.3: Verifying chart URL with digest (oci://reg/chart@sha256:...) does not trigger validation error" +set +e +${helmfile} -f "${issue_2097_input_dir}/helmfile-digest-in-url.yaml" template > "${issue_2097_tmp_dir}/digest-in-url.txt" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/digest-in-url.txt"; then + cat "${issue_2097_tmp_dir}/digest-in-url.txt" + rm -rf "${issue_2097_tmp_dir}" + fail "Issue #2097 regression: digest in chart URL triggered validation error" +fi + +info "SUCCESS: digest in chart URL does not trigger validation error" + +# ============================================================================================================== +# PART 2: Comprehensive Registry Tests (requires Docker) +# ============================================================================================================== + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + info "Skipping registry tests (Docker not available)" + rm -rf "${issue_2097_tmp_dir}" + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi + +# Check if Docker daemon is running +if ! docker info &> /dev/null; then + info "Skipping registry tests (Docker daemon not running)" + rm -rf "${issue_2097_tmp_dir}" + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi + +info "Part 2: Comprehensive tests with real OCI registry" + +registry_container_name="helmfile-test-registry-2097" +registry_port=5097 + +# Cleanup function +cleanup_registry_2097() { + info "Cleaning up test registry (issue-2097)" + docker stop ${registry_container_name} &>/dev/null || true + docker rm ${registry_container_name} &>/dev/null || true + rm -rf "${issue_2097_tmp_dir}" +} + +# Ensure cleanup on exit +trap cleanup_registry_2097 EXIT + +# Test 2.1: Start local OCI registry +info "Test 2.1: Starting local OCI registry on port ${registry_port}" +docker run -d \ + --name ${registry_container_name} \ + -p ${registry_port}:5000 \ + --rm \ + registry:2 &> "${issue_2097_tmp_dir}/registry-start.log" + +if [ $? -ne 0 ]; then + cat "${issue_2097_tmp_dir}/registry-start.log" + warn "Failed to start Docker registry - skipping registry tests" + rm -rf "${issue_2097_tmp_dir}" + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi + +# Wait for registry to be ready +info "Waiting for registry to be ready..." +max_attempts=30 +attempt=0 +while [ $attempt -lt $max_attempts ]; do + if curl -s http://localhost:${registry_port}/v2/ > /dev/null 2>&1; then + info "Registry is ready" + break + fi + attempt=$((attempt + 1)) + sleep 1 +done + +if [ $attempt -eq $max_attempts ]; then + warn "Registry did not become ready in time - skipping registry tests" + cleanup_registry_2097 + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi + +# Test 2.2: Package and push the test chart +info "Test 2.2: Packaging and pushing test charts" +set +e +${helm} package "${issue_2097_chart_dir}" -d "${issue_2097_tmp_dir}" > "${issue_2097_tmp_dir}/package.log" 2>&1 +if [ $? -ne 0 ]; then + set -e + cat "${issue_2097_tmp_dir}/package.log" + warn "Failed to package chart - skipping registry tests" + cleanup_registry_2097 + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi +set -e + +info "Pushing chart version 1.0.0 to local registry" +set +e +${helm} push "${issue_2097_tmp_dir}/test-chart-2097-1.0.0.tgz" oci://localhost:${registry_port} --plain-http > "${issue_2097_tmp_dir}/push.log" 2>&1 +if [ $? -ne 0 ]; then + set -e + cat "${issue_2097_tmp_dir}/push.log" + warn "Failed to push chart to registry - skipping registry tests" + cleanup_registry_2097 + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi +set -e + +# Create version 2.0.0 as well +info "Creating and pushing version 2.0.0" +cp -r "${issue_2097_chart_dir}" "${issue_2097_tmp_dir}/chart-v2" +sed -i.bak 's/version: 1.0.0/version: 2.0.0/' "${issue_2097_tmp_dir}/chart-v2/Chart.yaml" +set +e +${helm} package "${issue_2097_tmp_dir}/chart-v2" -d "${issue_2097_tmp_dir}" > "${issue_2097_tmp_dir}/package-v2.log" 2>&1 +${helm} push "${issue_2097_tmp_dir}/test-chart-2097-2.0.0.tgz" oci://localhost:${registry_port} --plain-http > "${issue_2097_tmp_dir}/push-v2.log" 2>&1 +set -e + +info "Successfully pushed chart versions 1.0.0 and 2.0.0" + +# Get the digest of the v1.0.0 chart +info "Fetching digest for v1.0.0" +set +e +chart_digest=$(${helm} pull oci://localhost:${registry_port}/test-chart-2097 --version 1.0.0 --plain-http -d "${issue_2097_tmp_dir}/pull-test" 2>&1 | grep -oE 'sha256:[a-f0-9]+') +set -e + +if [ -z "$chart_digest" ]; then + # Try alternative: use the registry API + chart_digest=$(curl -s http://localhost:${registry_port}/v2/test-chart-2097/manifests/1.0.0 \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -D - -o /dev/null 2>/dev/null | grep -i "docker-content-digest" | tr -d '\r' | awk '{print $2}') +fi + +if [ -z "$chart_digest" ]; then + warn "Could not determine chart digest - skipping digest registry tests" + cleanup_registry_2097 + trap - EXIT + test_pass "issue-2097: OCI chart digest support (validation tests only)" + return 0 +fi + +info "Chart v1.0.0 digest: ${chart_digest}" + +# Test 2.3: Baseline - chart with version in field (should work) +info "Test 2.3: Baseline - chart with version in field" +cat > "${issue_2097_tmp_dir}/helmfile-baseline.yaml" < "${issue_2097_tmp_dir}/template-baseline.yaml" 2>&1 +code=$? +set -e + +if [ $code -ne 0 ]; then + cat "${issue_2097_tmp_dir}/template-baseline.yaml" + cleanup_registry_2097 + fail "Baseline test failed - cannot proceed with digest tests" +fi + +info "SUCCESS: Baseline test passed" +if grep -q "Hello from test chart 1.0.0" "${issue_2097_tmp_dir}/template-baseline.yaml"; then + info "SUCCESS: Correctly pulled version 1.0.0" +fi + +# Test 2.4: Version tag in chart URL +info "Test 2.4: Version tag in chart URL" +cat > "${issue_2097_tmp_dir}/helmfile-version-url.yaml" < "${issue_2097_tmp_dir}/template-version-url.yaml" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/template-version-url.yaml"; then + cat "${issue_2097_tmp_dir}/template-version-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Version in chart URL triggered validation error" +fi + +if [ $code -ne 0 ]; then + cat "${issue_2097_tmp_dir}/template-version-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Version in chart URL failed" +fi + +info "SUCCESS: Version in chart URL works" +if grep -q "Hello from test chart 1.0.0" "${issue_2097_tmp_dir}/template-version-url.yaml"; then + info "SUCCESS: Correctly pulled version 1.0.0 from URL tag" +fi + +# Test 2.5: Digest in chart URL +info "Test 2.5: Digest in chart URL" +cat > "${issue_2097_tmp_dir}/helmfile-digest-url.yaml" < "${issue_2097_tmp_dir}/template-digest-url.yaml" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/template-digest-url.yaml"; then + cat "${issue_2097_tmp_dir}/template-digest-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Digest in chart URL triggered validation error" +fi + +if [ $code -ne 0 ]; then + cat "${issue_2097_tmp_dir}/template-digest-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Digest in chart URL failed" +fi + +info "SUCCESS: Digest in chart URL works" + +# Test 2.6: Digest in version field +info "Test 2.6: Digest in version field" +cat > "${issue_2097_tmp_dir}/helmfile-digest-version.yaml" < "${issue_2097_tmp_dir}/template-digest-version.yaml" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/template-digest-version.yaml"; then + cat "${issue_2097_tmp_dir}/template-digest-version.yaml" + cleanup_registry_2097 + fail "Issue #2097: Digest in version field triggered validation error" +fi + +if [ $code -ne 0 ]; then + cat "${issue_2097_tmp_dir}/template-digest-version.yaml" + cleanup_registry_2097 + fail "Issue #2097: Digest in version field failed" +fi + +info "SUCCESS: Digest in version field works" +if grep -q "Hello from test chart 1.0.0" "${issue_2097_tmp_dir}/template-digest-version.yaml"; then + info "SUCCESS: Correctly pulled version 1.0.0 with digest verification" +fi + +# Test 2.7: Version + digest in chart URL +info "Test 2.7: Version + digest in chart URL" +cat > "${issue_2097_tmp_dir}/helmfile-both-url.yaml" < "${issue_2097_tmp_dir}/template-both-url.yaml" 2>&1 +code=$? +set -e + +if grep -q "semver compliant" "${issue_2097_tmp_dir}/template-both-url.yaml"; then + cat "${issue_2097_tmp_dir}/template-both-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Version + digest in chart URL triggered validation error" +fi + +if [ $code -ne 0 ]; then + cat "${issue_2097_tmp_dir}/template-both-url.yaml" + cleanup_registry_2097 + fail "Issue #2097: Version + digest in chart URL failed" +fi + +info "SUCCESS: Version + digest in chart URL works" + +# All tests passed! +# Remove the EXIT trap to avoid interfering with subsequent tests +cleanup_registry_2097 +trap - EXIT +test_pass "issue-2097: OCI chart digest support (all tests including registry)" diff --git a/test/integration/test-cases/issue-2097/chart/Chart.yaml b/test/integration/test-cases/issue-2097/chart/Chart.yaml new file mode 100644 index 00000000..34f0a973 --- /dev/null +++ b/test/integration/test-cases/issue-2097/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: test-chart-2097 +description: Test chart for issue #2097 +type: application +version: 1.0.0 +appVersion: "1.0" diff --git a/test/integration/test-cases/issue-2097/chart/templates/configmap.yaml b/test/integration/test-cases/issue-2097/chart/templates/configmap.yaml new file mode 100644 index 00000000..67b956ed --- /dev/null +++ b/test/integration/test-cases/issue-2097/chart/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-config + labels: + app: {{ .Chart.Name }} +data: + message: "Hello from test chart {{ .Chart.Version }}" diff --git a/test/integration/test-cases/issue-2097/chart/values.yaml b/test/integration/test-cases/issue-2097/chart/values.yaml new file mode 100644 index 00000000..4178b104 --- /dev/null +++ b/test/integration/test-cases/issue-2097/chart/values.yaml @@ -0,0 +1,3 @@ +# Default values for test-chart-2097 +# This is a minimal chart for testing OCI digest handling +replicaCount: 1 diff --git a/test/integration/test-cases/issue-2097/input/helmfile-digest-in-url.yaml b/test/integration/test-cases/issue-2097/input/helmfile-digest-in-url.yaml new file mode 100644 index 00000000..859481fb --- /dev/null +++ b/test/integration/test-cases/issue-2097/input/helmfile-digest-in-url.yaml @@ -0,0 +1,4 @@ +releases: + - name: test-oci-digest-in-url + namespace: default + chart: oci://registry.example.com/my-chart@sha256:abc123def456 diff --git a/test/integration/test-cases/issue-2097/input/helmfile-digest-in-version.yaml b/test/integration/test-cases/issue-2097/input/helmfile-digest-in-version.yaml new file mode 100644 index 00000000..b8ed93b6 --- /dev/null +++ b/test/integration/test-cases/issue-2097/input/helmfile-digest-in-version.yaml @@ -0,0 +1,5 @@ +releases: + - name: test-oci-digest-version + namespace: default + chart: oci://registry.example.com/my-chart + version: "2.0.0@sha256:abc123def456" diff --git a/test/integration/test-cases/issue-2097/input/helmfile-version-in-url.yaml b/test/integration/test-cases/issue-2097/input/helmfile-version-in-url.yaml new file mode 100644 index 00000000..8eb23e6b --- /dev/null +++ b/test/integration/test-cases/issue-2097/input/helmfile-version-in-url.yaml @@ -0,0 +1,4 @@ +releases: + - name: test-oci-version-in-url + namespace: default + chart: oci://registry.example.com/my-chart:2.0.0