From ab7e388b1e569c436c48f013168fe82fcd9d6f12 Mon Sep 17 00:00:00 2001 From: Raymond Barbiero Date: Wed, 14 Oct 2026 17:45:35 -0400 Subject: [PATCH] Add manifest and config outputs to helm_oci_digest Adds optional `-manifest_output` and `-config_output` to the digest tool, surfaced on `HelmOCIDigestInfo` and as `manifest` / `config` output groups so a BUILD file can reach them. A consumer that pushes a chart has to PUT the manifest body, not just name it by digest. Emitting the bytes saves it reimplementing the annotation generation and config serialization to produce something that matches. --- helm/private/oci_digest.bzl | 25 +++++++++++++++++++++--- helm/private/oci_digest/oci_digest.go | 19 ++++++++++++++++++ tests/simple/BUILD.bazel | 26 +++++++++++++++++++++++++ tests/simple/simple.oci_config.golden | 1 + tests/simple/simple.oci_manifest.golden | 1 + 5 files changed, 69 insertions(+), 3 deletions(-) create mode 100755 tests/simple/simple.oci_config.golden create mode 100755 tests/simple/simple.oci_manifest.golden diff --git a/helm/private/oci_digest.bzl b/helm/private/oci_digest.bzl index eb0a28c6..aba8de7a 100644 --- a/helm/private/oci_digest.bzl +++ b/helm/private/oci_digest.bzl @@ -5,7 +5,9 @@ load(":providers.bzl", "HelmPackageInfo") HelmOCIDigestInfo = provider( doc = "OCI manifest digest for a Helm chart, computed at build time.", fields = { + "config": "File: The raw OCI config blob (application/vnd.cncf.helm.config.v1+json)", "digest": "File: Text file containing the OCI manifest digest (sha256:...)", + "manifest": "File: The raw OCI manifest bytes whose digest is in `digest`", }, ) @@ -13,17 +15,21 @@ def _helm_oci_digest_impl(ctx): pkg_info = ctx.attr.chart[HelmPackageInfo] digest_out = ctx.actions.declare_file(ctx.label.name + ".digest") + manifest_out = ctx.actions.declare_file(ctx.label.name + ".manifest.json") + config_out = ctx.actions.declare_file(ctx.label.name + ".config.json") args = ctx.actions.args() args.add("-chart", pkg_info.chart) args.add("-metadata", pkg_info.metadata) args.add("-digest_output", digest_out) + args.add("-manifest_output", manifest_out) + args.add("-config_output", config_out) ctx.actions.run( executable = ctx.executable._oci_digest_tool, arguments = [args], inputs = [pkg_info.chart, pkg_info.metadata], - outputs = [digest_out], + outputs = [digest_out, manifest_out, config_out], mnemonic = "HelmOCIDigest", progress_message = "Computing OCI digest for %s" % ctx.label, ) @@ -34,6 +40,14 @@ def _helm_oci_digest_impl(ctx): ), HelmOCIDigestInfo( digest = digest_out, + manifest = manifest_out, + config = config_out, + ), + # Also as output groups, so the blobs are reachable from a BUILD file + # (filegroup, diff_test) and not only from a rule implementation. + OutputGroupInfo( + config = depset([config_out]), + manifest = depset([manifest_out]), ), # Forward HelmPackageInfo so downstream rules can access # both providers from a single target. @@ -45,10 +59,15 @@ helm_oci_digest = rule( doc = """Compute the OCI manifest digest for a Helm chart at build time. This rule constructs the OCI manifest that `helm push` (Helm v4) would -create and computes its sha256 digest. The .tgz file's mtime is used for -the creation annotation, formatted in UTC to match the push side. +create and computes its sha256 digest. The creation annotation comes from +the chart's own `org.opencontainers.image.created` annotation, resolved by +the stamping pass, and is formatted in UTC to match the push side. The digest file contains a single line: `sha256:`. + +The manifest and config blobs are also emitted, so a pusher that uploads +them itself can PUT the exact bytes this digest was computed over. They are +not in the default outputs; read them off `HelmOCIDigestInfo`. """, attrs = { "chart": attr.label( diff --git a/helm/private/oci_digest/oci_digest.go b/helm/private/oci_digest/oci_digest.go index be582e0f..dbeeea66 100644 --- a/helm/private/oci_digest/oci_digest.go +++ b/helm/private/oci_digest/oci_digest.go @@ -127,6 +127,8 @@ func main() { chartTgz := flag.String("chart", "", "Path to the Helm chart .tgz file") metadataJSON := flag.String("metadata", "", "Path to the chart metadata JSON file") digestOutput := flag.String("digest_output", "", "Path to write the manifest digest (sha256:...)") + manifestOutput := flag.String("manifest_output", "", "Optional path to write the OCI manifest bytes") + configOutput := flag.String("config_output", "", "Optional path to write the OCI config blob bytes") flag.Parse() @@ -214,4 +216,21 @@ func main() { if err := os.WriteFile(*digestOutput, []byte(digest.String()), 0644); err != nil { log.Fatalf("Error writing digest file: %v", err) } + + // The manifest and config blobs are written verbatim, never re-marshalled by + // a consumer: re-serializing this JSON can reorder keys or change whitespace, + // which changes the digest above and invalidates every descriptor that + // references it. A pusher that uploads the blobs itself needs these exact + // bytes to PUT. + if *manifestOutput != "" { + if err := os.WriteFile(*manifestOutput, manifestBytes, 0644); err != nil { + log.Fatalf("Error writing manifest file: %v", err) + } + } + + if *configOutput != "" { + if err := os.WriteFile(*configOutput, configBlob, 0644); err != nil { + log.Fatalf("Error writing config file: %v", err) + } + } } diff --git a/tests/simple/BUILD.bazel b/tests/simple/BUILD.bazel index ceaa834c..01f03450 100644 --- a/tests/simple/BUILD.bazel +++ b/tests/simple/BUILD.bazel @@ -54,3 +54,29 @@ diff_test( file1 = "simple.oci_digest.golden", file2 = ":simple.oci_digest", ) + +filegroup( + name = "simple_oci_manifest", + srcs = [":simple.oci_digest"], + output_group = "manifest", +) + +filegroup( + name = "simple_oci_config", + srcs = [":simple.oci_digest"], + output_group = "config", +) + +# The digest above is the sha256 of this manifest, so locking both keeps them +# honest: changing one without the other fails here. +diff_test( + name = "simple_oci_manifest_test", + file1 = "simple.oci_manifest.golden", + file2 = ":simple_oci_manifest", +) + +diff_test( + name = "simple_oci_config_test", + file1 = "simple.oci_config.golden", + file2 = ":simple_oci_config", +) diff --git a/tests/simple/simple.oci_config.golden b/tests/simple/simple.oci_config.golden new file mode 100755 index 00000000..f1ac4931 --- /dev/null +++ b/tests/simple/simple.oci_config.golden @@ -0,0 +1 @@ +{"name":"simple","version":"0.1.0","description":"A Helm chart for Kubernetes","icon":"https://helm.sh/img/helm.svg","apiVersion":"v2","appVersion":"1.16.0","type":"application"} \ No newline at end of file diff --git a/tests/simple/simple.oci_manifest.golden b/tests/simple/simple.oci_manifest.golden new file mode 100755 index 00000000..d25ae976 --- /dev/null +++ b/tests/simple/simple.oci_manifest.golden @@ -0,0 +1 @@ +{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"sha256:8a48b77fb50ca4dad5251f0cf7a1faa443201a3bf572ff09eba16ce913d0a52a","size":178},"layers":[{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"sha256:c3bce8332d61329ca952ee7c4d121dae03c86e4682951a1b0a4a66d94a92ab57","size":3856}],"annotations":{"org.opencontainers.image.created":"1970-01-01T00:00:00Z","org.opencontainers.image.description":"A Helm chart for Kubernetes","org.opencontainers.image.title":"simple","org.opencontainers.image.version":"0.1.0"}} \ No newline at end of file