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 <amenon@canarytechnologies.com>
This commit is contained in:
Aditya Menon
2026-02-12 20:20:43 +08:00
committed by GitHub
parent 2f8b9cbdfb
commit 5c43fa6465
14 changed files with 910 additions and 10 deletions
+10 -2
View File
@@ -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
}
+24
View File
@@ -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]
+45
View File
@@ -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 {
+118
View File
@@ -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")
})
}
}
+113 -8
View File
@@ -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
}
+206
View File
@@ -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 {