From d289818a139abe1d0fc21ef4f0afb6b526e9c59d Mon Sep 17 00:00:00 2001 From: Richard Clark Date: Mon, 19 Oct 2026 17:55:24 +0100 Subject: [PATCH] Add `helm_oci_manifest` so `rules_img`'s `multi_deploy` can push a chart `helm_push` cannot provide `DeployInfo`, so a chart could not be one of a `multi_deploy`'s operations. `helm_oci_manifest` in the new helm/rules_img.bzl describes the chart as the OCI artifact `helm push` creates: an `image_manifest` with the Helm config media type, the chart .tgz as its only layer, the annotations `helm push` writes, and a push spec for `/:`. The manifest sets `omit_media_type`, because `helm push` writes no top-level `mediaType`. The pushed manifest is then byte-identical to the `helm push` one, and its digest is the one `helm_oci_digest` gives. Move the chart-reading and annotation code from oci_digest into a shared ociartifact package, add an oci_parts tool for the rules_img inputs, and make rules_img a non-dev dependency at 0.3.24, the first release with `omit_media_type`. --- MODULE.bazel | 2 +- docs/BUILD.bazel | 10 + docs/src/SUMMARY.md | 1 + docs/src/rules.md | 1 + helm/BUILD.bazel | 13 ++ helm/private/oci_digest/BUILD.bazel | 8 +- helm/private/oci_digest/chartmeta/BUILD.bazel | 2 +- helm/private/oci_digest/oci_digest.go | 188 +--------------- .../oci_digest/ociartifact/BUILD.bazel | 21 ++ .../oci_digest/ociartifact/ociartifact.go | 204 ++++++++++++++++++ .../ociartifact/ociartifact_test.go | 119 ++++++++++ helm/private/oci_parts.bzl | 93 ++++++++ helm/private/oci_parts/BUILD.bazel | 21 ++ helm/private/oci_parts/oci_parts.go | 112 ++++++++++ helm/private/oci_parts/oci_parts_test.go | 113 ++++++++++ helm/rules_img.bzl | 119 ++++++++++ tests/helm_oci_manifest/BUILD.bazel | 158 ++++++++++++++ .../helm_oci_manifest_test.go | 187 ++++++++++++++++ .../simple.helm_push_config.golden | 1 + .../simple.helm_push_manifest.golden | 1 + 20 files changed, 1183 insertions(+), 191 deletions(-) create mode 100644 helm/private/oci_digest/ociartifact/BUILD.bazel create mode 100644 helm/private/oci_digest/ociartifact/ociartifact.go create mode 100644 helm/private/oci_digest/ociartifact/ociartifact_test.go create mode 100644 helm/private/oci_parts.bzl create mode 100644 helm/private/oci_parts/BUILD.bazel create mode 100644 helm/private/oci_parts/oci_parts.go create mode 100644 helm/private/oci_parts/oci_parts_test.go create mode 100644 helm/rules_img.bzl create mode 100644 tests/helm_oci_manifest/BUILD.bazel create mode 100644 tests/helm_oci_manifest/helm_oci_manifest_test.go create mode 100644 tests/helm_oci_manifest/simple.helm_push_config.golden create mode 100644 tests/helm_oci_manifest/simple.helm_push_manifest.golden diff --git a/MODULE.bazel b/MODULE.bazel index d6c1df19..38f4ca68 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -11,9 +11,9 @@ bazel_dep(name = "bazel_skylib", version = "1.9.2") bazel_dep(name = "rules_go", version = "0.63.0") bazel_dep(name = "gazelle", version = "0.53.0") bazel_dep(name = "bazel_lib", version = "3.0.0") +bazel_dep(name = "rules_img", version = "0.3.24") bazel_dep(name = "rules_oci", version = "2.3.0", dev_dependency = True) -bazel_dep(name = "rules_img", version = "0.3.18", dev_dependency = True) bazel_dep(name = "stardoc", version = "0.8.1", dev_dependency = True) bazel_dep(name = "rules_rust_mdbook", version = "0.69.0", dev_dependency = True) bazel_dep(name = "rules_shell", version = "0.6.1", dev_dependency = True) diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index fb4345af..6e3e5a32 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -145,6 +145,15 @@ stardoc( deps = ["//helm:helm_toolchain_bzl"], ) +stardoc( + name = "rules_img", + out = "src/rules_img.md", + input = "//helm:rules_img.bzl", + table_of_contents_template = "@stardoc//stardoc:templates/markdown_tables/table_of_contents.vm", + tags = ["manual"], + deps = ["//helm:rules_img_bzl"], +) + stardoc( name = "k8s_defs", out = "src/k8s_defs.md", @@ -183,6 +192,7 @@ mdbook( ":helm_toolchain", ":k8s_defs", ":k8s_extensions", + ":rules_img", ":settings", ], book = "book.toml", diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c854454b..5b643891 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -17,6 +17,7 @@ - [helm_push](./helm_push.md) - [helm_template](./helm_template.md) - [helm_toolchain](./helm_toolchain.md) + - [rules_img](./rules_img.md) - [Extensions](./extensions.md) diff --git a/docs/src/rules.md b/docs/src/rules.md index 7647ba10..1a2d479c 100644 --- a/docs/src/rules.md +++ b/docs/src/rules.md @@ -9,6 +9,7 @@ - [helm_lint_aspect](./helm_lint.md#helm_lint_aspect) - [helm_lint_test](./helm_lint.md#helm_lint_test) - [helm_oci_digest](./helm_oci_digest.md#helm_oci_digest) +- [helm_oci_manifest](./rules_img.md#helm_oci_manifest) - [helm_package](./helm_package.md#helm_package) - [helm_plugin](./helm_plugin.md#helm_plugin) - [helm_push_images](./helm_push.md#helm_push_images) diff --git a/helm/BUILD.bazel b/helm/BUILD.bazel index c0df1890..712e665f 100644 --- a/helm/BUILD.bazel +++ b/helm/BUILD.bazel @@ -18,6 +18,7 @@ exports_files( "helm_push.bzl", "helm_template.bzl", "helm_toolchain.bzl", + "rules_img.bzl", ], visibility = ["//visibility:public"], ) @@ -162,3 +163,15 @@ bzl_library( visibility = ["//visibility:public"], deps = ["//helm/private:bzl_lib"], ) + +bzl_library( + name = "rules_img_bzl", + srcs = ["rules_img.bzl"], + visibility = ["//visibility:public"], + deps = [ + "//helm/private:bzl_lib", + "@rules_img//img:image", + "@rules_img//img:layer", + "@rules_img//img:push", + ], +) diff --git a/helm/private/oci_digest/BUILD.bazel b/helm/private/oci_digest/BUILD.bazel index 2eb4b45b..e124a159 100644 --- a/helm/private/oci_digest/BUILD.bazel +++ b/helm/private/oci_digest/BUILD.bazel @@ -4,11 +4,5 @@ go_binary( name = "oci_digest", srcs = ["oci_digest.go"], visibility = ["//visibility:public"], - deps = [ - "//helm/private/oci_digest/chartmeta", - "@com_github_opencontainers_go_digest//:go-digest", - "@com_github_opencontainers_image_spec//specs-go", - "@com_github_opencontainers_image_spec//specs-go/v1:specs-go", - "@io_k8s_sigs_yaml//:yaml", - ], + deps = ["//helm/private/oci_digest/ociartifact"], ) diff --git a/helm/private/oci_digest/chartmeta/BUILD.bazel b/helm/private/oci_digest/chartmeta/BUILD.bazel index b93d875f..e502e98c 100644 --- a/helm/private/oci_digest/chartmeta/BUILD.bazel +++ b/helm/private/oci_digest/chartmeta/BUILD.bazel @@ -4,5 +4,5 @@ go_library( name = "chartmeta", srcs = ["chartmeta.go"], importpath = "github.com/periareon/rules_helm/helm/private/oci_digest/chartmeta", - visibility = ["//helm/private/oci_digest:__pkg__"], + visibility = ["//helm/private/oci_digest/ociartifact:__pkg__"], ) diff --git a/helm/private/oci_digest/oci_digest.go b/helm/private/oci_digest/oci_digest.go index dbeeea66..48212df9 100644 --- a/helm/private/oci_digest/oci_digest.go +++ b/helm/private/oci_digest/oci_digest.go @@ -5,124 +5,13 @@ package main import ( - "archive/tar" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" "flag" - "fmt" - "io" "log" "os" - "strings" - godigest "github.com/opencontainers/go-digest" - specs "github.com/opencontainers/image-spec/specs-go" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "sigs.k8s.io/yaml" - - "github.com/periareon/rules_helm/helm/private/oci_digest/chartmeta" -) - -// Helm-specific OCI media types. -const ( - HelmConfigMediaType = "application/vnd.cncf.helm.config.v1+json" - HelmChartMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + "github.com/periareon/rules_helm/helm/private/oci_digest/ociartifact" ) -func sha256Digest(data []byte) godigest.Digest { - h := sha256.Sum256(data) - return godigest.NewDigestFromEncoded(godigest.SHA256, hex.EncodeToString(h[:])) -} - -func readChartYAMLFromTgz(tgzPath string) ([]byte, error) { - file, err := os.Open(tgzPath) - if err != nil { - return nil, fmt.Errorf("opening tarball %s: %w", tgzPath, err) - } - defer file.Close() - - gz, err := gzip.NewReader(file) - if err != nil { - return nil, fmt.Errorf("creating gzip reader: %w", err) - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, fmt.Errorf("reading tar: %w", err) - } - - // Chart.yaml is at /Chart.yaml - parts := strings.Split(header.Name, "/") - if len(parts) == 2 && parts[1] == "Chart.yaml" { - return io.ReadAll(tr) - } - } - - return nil, errors.New("Chart.yaml not found in tarball") -} - -// generateAnnotations replicates Helm v4's unexported generateOCIAnnotations -// (pkg/registry/chart.go). When reproducible, uses a fixed creation timestamp; -// otherwise uses the provided creation time from the chart .tgz mtime. -func generateAnnotations(meta *chartmeta.Metadata, creationTime string) map[string]string { - annotations := make(map[string]string) - - // Helm's addToMap pattern: only set if non-empty after trim - addToMap := func(key, value string) { - if len(strings.TrimSpace(value)) > 0 { - annotations[key] = value - } - } - - addToMap(ocispec.AnnotationDescription, meta.Description) - addToMap(ocispec.AnnotationTitle, meta.Name) - addToMap(ocispec.AnnotationVersion, meta.Version) - addToMap(ocispec.AnnotationURL, meta.Home) - - annotations[ocispec.AnnotationCreated] = creationTime - - if len(meta.Sources) > 0 { - addToMap(ocispec.AnnotationSource, meta.Sources[0]) - } - - if len(meta.Maintainers) > 0 { - var sb strings.Builder - for i, m := range meta.Maintainers { - if len(m.Name) > 0 { - sb.WriteString(m.Name) - } - if len(m.Email) > 0 { - sb.WriteString(" (") - sb.WriteString(m.Email) - sb.WriteString(")") - } - if i < len(meta.Maintainers)-1 { - sb.WriteString(", ") - } - } - addToMap(ocispec.AnnotationAuthors, sb.String()) - } - - // Merge custom annotations from Chart.yaml, but never override title/version - for k, v := range meta.Annotations { - if k == ocispec.AnnotationTitle || k == ocispec.AnnotationVersion { - continue - } - annotations[k] = v - } - - return annotations -} - func main() { chartTgz := flag.String("chart", "", "Path to the Helm chart .tgz file") metadataJSON := flag.String("metadata", "", "Path to the chart metadata JSON file") @@ -136,83 +25,18 @@ func main() { log.Fatal("Required flags: -chart, -metadata, -digest_output") } - metadataBytes, err := os.ReadFile(*metadataJSON) + artifact, err := ociartifact.Load(*chartTgz, *metadataJSON) if err != nil { - log.Fatalf("Error reading metadata file: %v", err) + log.Fatalf("Error loading chart: %v", err) } - var metadata struct { - Created string `json:"created"` - } - if err := json.Unmarshal(metadataBytes, &metadata); err != nil { - log.Fatalf("Error parsing metadata JSON: %v", err) - } - if metadata.Created == "" { - log.Fatalf("metadata.json missing required `created` field") - } - creationTime := metadata.Created - // Read Chart.yaml from the .tgz - chartYAML, err := readChartYAMLFromTgz(*chartTgz) - if err != nil { - log.Fatalf("Error reading Chart.yaml from .tgz: %v", err) - } - - // Parse into chartmeta.Metadata (vendored from Helm — guarantees identical JSON tags) - var chartMeta chartmeta.Metadata - if err := yaml.Unmarshal(chartYAML, &chartMeta); err != nil { - log.Fatalf("Error parsing Chart.yaml: %v", err) - } - - // Apply the same field mutations Helm's loader performs before serialization: - // sanitizeString on all text fields, APIVersion defaulting to "v1". - chartmeta.PrepareForSerialization(&chartMeta) - - // Serialize config blob as JSON — byte-identical to helm push - configBlob, err := json.Marshal(&chartMeta) - if err != nil { - log.Fatalf("Error marshalling config blob: %v", err) - } - - // Read chart .tgz for layer digest - tgzBytes, err := os.ReadFile(*chartTgz) - if err != nil { - log.Fatalf("Error reading chart .tgz: %v", err) - } - - // Compute content digests - configDigest := sha256Digest(configBlob) - tgzDigest := sha256Digest(tgzBytes) - - // Build OCI annotations - annotations := generateAnnotations(&chartMeta, creationTime) - - // Build OCI manifest using the same types as Helm v4 - manifest := ocispec.Manifest{ - Versioned: specs.Versioned{SchemaVersion: 2}, - Config: ocispec.Descriptor{ - MediaType: HelmConfigMediaType, - Digest: configDigest, - Size: int64(len(configBlob)), - }, - Layers: []ocispec.Descriptor{ - { - MediaType: HelmChartMediaType, - Digest: tgzDigest, - Size: int64(len(tgzBytes)), - }, - }, - Annotations: annotations, - } - - manifestBytes, err := json.Marshal(manifest) + manifestBytes, err := artifact.Manifest() if err != nil { log.Fatalf("Error marshalling manifest: %v", err) } - // Compute manifest digest - digest := sha256Digest(manifestBytes) - // Write digest (no trailing newline) + digest := ociartifact.Digest(manifestBytes) if err := os.WriteFile(*digestOutput, []byte(digest.String()), 0644); err != nil { log.Fatalf("Error writing digest file: %v", err) } @@ -229,7 +53,7 @@ func main() { } if *configOutput != "" { - if err := os.WriteFile(*configOutput, configBlob, 0644); err != nil { + if err := os.WriteFile(*configOutput, artifact.ConfigBlob, 0644); err != nil { log.Fatalf("Error writing config file: %v", err) } } diff --git a/helm/private/oci_digest/ociartifact/BUILD.bazel b/helm/private/oci_digest/ociartifact/BUILD.bazel new file mode 100644 index 00000000..62952440 --- /dev/null +++ b/helm/private/oci_digest/ociartifact/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "ociartifact", + srcs = ["ociartifact.go"], + importpath = "github.com/periareon/rules_helm/helm/private/oci_digest/ociartifact", + visibility = ["//helm/private:__subpackages__"], + deps = [ + "//helm/private/oci_digest/chartmeta", + "@com_github_opencontainers_go_digest//:go-digest", + "@com_github_opencontainers_image_spec//specs-go", + "@com_github_opencontainers_image_spec//specs-go/v1:specs-go", + "@io_k8s_sigs_yaml//:yaml", + ], +) + +go_test( + name = "ociartifact_test", + srcs = ["ociartifact_test.go"], + embed = [":ociartifact"], +) diff --git a/helm/private/oci_digest/ociartifact/ociartifact.go b/helm/private/oci_digest/ociartifact/ociartifact.go new file mode 100644 index 00000000..b8d313db --- /dev/null +++ b/helm/private/oci_digest/ociartifact/ociartifact.go @@ -0,0 +1,204 @@ +// Package ociartifact builds the parts of the OCI artifact that `helm push` +// (Helm v4) creates for a chart .tgz. +package ociartifact + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "sigs.k8s.io/yaml" + + "github.com/periareon/rules_helm/helm/private/oci_digest/chartmeta" +) + +// Helm-specific OCI media types. +const ( + HelmConfigMediaType = "application/vnd.cncf.helm.config.v1+json" + HelmChartMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" +) + +// Digest returns the sha256 digest of data. +func Digest(data []byte) godigest.Digest { + h := sha256.Sum256(data) + return godigest.NewDigestFromEncoded(godigest.SHA256, hex.EncodeToString(h[:])) +} + +// Artifact holds the config blob, layer and annotations of a chart's OCI artifact. +type Artifact struct { + // Metadata is the chart's Chart.yaml after Helm's serialization mutations. + Metadata chartmeta.Metadata + // ConfigBlob is the config blob, byte-identical to the one `helm push` sends. + ConfigBlob []byte + // Chart is the chart .tgz, which is the only layer. + Chart []byte + Annotations map[string]string +} + +// Load reads a chart .tgz and the metadata.json written alongside it by +// helm_package. +func Load(chartPath, metadataPath string) (*Artifact, error) { + metadataBytes, err := os.ReadFile(metadataPath) + if err != nil { + return nil, fmt.Errorf("reading metadata file: %w", err) + } + var metadata struct { + Created string `json:"created"` + } + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return nil, fmt.Errorf("parsing metadata JSON: %w", err) + } + if metadata.Created == "" { + return nil, errors.New("metadata.json missing required `created` field; charts from helm_import are not supported") + } + + chartYAML, err := readChartYAMLFromTgz(chartPath) + if err != nil { + return nil, fmt.Errorf("reading Chart.yaml from .tgz: %w", err) + } + + // Parse into chartmeta.Metadata (vendored from Helm — guarantees identical JSON tags) + var chartMeta chartmeta.Metadata + if err := yaml.Unmarshal(chartYAML, &chartMeta); err != nil { + return nil, fmt.Errorf("parsing Chart.yaml: %w", err) + } + + // Apply the same field mutations Helm's loader performs before serialization: + // sanitizeString on all text fields, APIVersion defaulting to "v1". + chartmeta.PrepareForSerialization(&chartMeta) + + // Serialize config blob as JSON — byte-identical to helm push + configBlob, err := json.Marshal(&chartMeta) + if err != nil { + return nil, fmt.Errorf("marshalling config blob: %w", err) + } + + chart, err := os.ReadFile(chartPath) + if err != nil { + return nil, fmt.Errorf("reading chart .tgz: %w", err) + } + + return &Artifact{ + Metadata: chartMeta, + ConfigBlob: configBlob, + Chart: chart, + Annotations: generateAnnotations(&chartMeta, metadata.Created), + }, nil +} + +// Manifest returns the manifest `helm push` creates, using the same types as Helm v4. +func (a *Artifact) Manifest() ([]byte, error) { + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Config: ocispec.Descriptor{ + MediaType: HelmConfigMediaType, + Digest: Digest(a.ConfigBlob), + Size: int64(len(a.ConfigBlob)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: HelmChartMediaType, + Digest: Digest(a.Chart), + Size: int64(len(a.Chart)), + }, + }, + Annotations: a.Annotations, + } + return json.Marshal(manifest) +} + +func readChartYAMLFromTgz(tgzPath string) ([]byte, error) { + file, err := os.Open(tgzPath) + if err != nil { + return nil, fmt.Errorf("opening tarball %s: %w", tgzPath, err) + } + defer file.Close() + + gz, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("creating gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar: %w", err) + } + + // Chart.yaml is at /Chart.yaml + parts := strings.Split(header.Name, "/") + if len(parts) == 2 && parts[1] == "Chart.yaml" { + return io.ReadAll(tr) + } + } + + return nil, errors.New("Chart.yaml not found in tarball") +} + +// generateAnnotations replicates Helm v4's unexported generateOCIAnnotations +// (pkg/registry/chart.go). creationTime is the `created` value helm_package +// records. Annotations from Chart.yaml override every key except title and +// version. +func generateAnnotations(meta *chartmeta.Metadata, creationTime string) map[string]string { + annotations := make(map[string]string) + + // Helm's addToMap pattern: only set if non-empty after trim + addToMap := func(key, value string) { + if len(strings.TrimSpace(value)) > 0 { + annotations[key] = value + } + } + + addToMap(ocispec.AnnotationDescription, meta.Description) + addToMap(ocispec.AnnotationTitle, meta.Name) + addToMap(ocispec.AnnotationVersion, meta.Version) + addToMap(ocispec.AnnotationURL, meta.Home) + + annotations[ocispec.AnnotationCreated] = creationTime + + if len(meta.Sources) > 0 { + addToMap(ocispec.AnnotationSource, meta.Sources[0]) + } + + if len(meta.Maintainers) > 0 { + var sb strings.Builder + for i, m := range meta.Maintainers { + if len(m.Name) > 0 { + sb.WriteString(m.Name) + } + if len(m.Email) > 0 { + sb.WriteString(" (") + sb.WriteString(m.Email) + sb.WriteString(")") + } + if i < len(meta.Maintainers)-1 { + sb.WriteString(", ") + } + } + addToMap(ocispec.AnnotationAuthors, sb.String()) + } + + for k, v := range meta.Annotations { + if k == ocispec.AnnotationTitle || k == ocispec.AnnotationVersion { + continue + } + annotations[k] = v + } + + return annotations +} diff --git a/helm/private/oci_digest/ociartifact/ociartifact_test.go b/helm/private/oci_digest/ociartifact/ociartifact_test.go new file mode 100644 index 00000000..6ec72d93 --- /dev/null +++ b/helm/private/oci_digest/ociartifact/ociartifact_test.go @@ -0,0 +1,119 @@ +package ociartifact + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// writeChart writes a chart .tgz containing only `/Chart.yaml`, plus a +// metadata.json with the given creation time, and returns their paths. +func writeChart(t *testing.T, name, chartYAML, created string) (string, string) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: name + "/Chart.yaml", Mode: 0644, Size: int64(len(chartYAML))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(chartYAML)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + chartPath := filepath.Join(dir, name+".tgz") + if err := os.WriteFile(chartPath, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + return chartPath, writeMetadata(t, created) +} + +func writeMetadata(t *testing.T, created string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "metadata.json") + if err := os.WriteFile(path, []byte(`{"created": "`+created+`"}`), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadAnnotations(t *testing.T) { + chartPath, metadataPath := writeChart(t, "demo", `apiVersion: v2 +name: demo +version: 1.2.3 +description: A demo chart +home: https://example.com +sources: + - https://example.com/first + - https://example.com/second +maintainers: + - name: Alice + email: alice@example.com + - name: Bob +annotations: + org.opencontainers.image.title: ignored + org.opencontainers.image.version: ignored + org.opencontainers.image.created: "2024-01-02T03:04:05Z" + example.com/custom: value +`, "1970-01-01T00:00:00Z") + + artifact, err := Load(chartPath, metadataPath) + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "org.opencontainers.image.authors": "Alice (alice@example.com), Bob", + // The chart's own annotation wins over the metadata's created time. + "org.opencontainers.image.created": "2024-01-02T03:04:05Z", + "org.opencontainers.image.description": "A demo chart", + "org.opencontainers.image.source": "https://example.com/first", + "org.opencontainers.image.title": "demo", + "org.opencontainers.image.url": "https://example.com", + "org.opencontainers.image.version": "1.2.3", + "example.com/custom": "value", + } + if !reflect.DeepEqual(artifact.Annotations, want) { + t.Errorf("Annotations = %v, want %v", artifact.Annotations, want) + } +} + +func TestLoadConfigBlob(t *testing.T) { + chartPath, metadataPath := writeChart(t, "demo", "name: demo\nversion: 1.2.3\ndescription: \"tab\\there\"\n", "1970-01-01T00:00:00Z") + + artifact, err := Load(chartPath, metadataPath) + if err != nil { + t.Fatal(err) + } + + // Helm defaults apiVersion to v1 and replaces whitespace control characters. + want := `{"name":"demo","version":"1.2.3","description":"tab here","apiVersion":"v1"}` + if got := string(artifact.ConfigBlob); got != want { + t.Errorf("ConfigBlob = %s, want %s", got, want) + } +} + +func TestLoadRequiresCreated(t *testing.T) { + chartPath, _ := writeChart(t, "demo", "name: demo\nversion: 1.2.3\n", "") + metadataPath := filepath.Join(t.TempDir(), "metadata.json") + if err := os.WriteFile(metadataPath, []byte(`{}`), 0644); err != nil { + t.Fatal(err) + } + + if _, err := Load(chartPath, metadataPath); err == nil || !strings.Contains(err.Error(), "created") { + t.Errorf("Load() error = %v, want a missing `created` error", err) + } +} diff --git a/helm/private/oci_parts.bzl b/helm/private/oci_parts.bzl new file mode 100644 index 00000000..5b55477e --- /dev/null +++ b/helm/private/oci_parts.bzl @@ -0,0 +1,93 @@ +"""The parts of the OCI artifact `helm push` creates, as files for `rules_img`.""" + +load(":providers.bzl", "HelmPackageInfo") + +def _helm_oci_parts_impl(ctx): + if ctx.attr.registry_url and ctx.attr.registry_url_file: + fail("`registry_url` and `registry_url_file` are mutually exclusive. Only one may be specified for target `{}`.".format(ctx.label)) + if not ctx.attr.registry_url and not ctx.attr.registry_url_file: + fail("Either `registry_url` or `registry_url_file` must be specified for target `{}`.".format(ctx.label)) + + pkg_info = ctx.attr.chart[HelmPackageInfo] + inputs = [pkg_info.chart, pkg_info.metadata] + outputs = [ + ctx.outputs.config_out, + ctx.outputs.annotations_out, + ctx.outputs.tag_out, + ctx.outputs.destination_out, + ] + + args = ctx.actions.args() + args.add("-chart", pkg_info.chart) + args.add("-metadata", pkg_info.metadata) + if ctx.attr.registry_url: + args.add("-registry_url", ctx.attr.registry_url) + else: + args.add("-registry_url_file", ctx.file.registry_url_file) + inputs.append(ctx.file.registry_url_file) + args.add("-config_output", ctx.outputs.config_out) + args.add("-annotations_output", ctx.outputs.annotations_out) + args.add("-tag_output", ctx.outputs.tag_out) + args.add("-destination_output", ctx.outputs.destination_out) + + ctx.actions.run( + executable = ctx.executable._oci_parts_tool, + arguments = [args], + inputs = inputs, + outputs = outputs, + mnemonic = "HelmOCIParts", + progress_message = "Computing OCI artifact parts for %s" % ctx.label, + ) + + # The layer must be the same .tgz the other parts were read from, which is + # not always the chart target's default output. + ctx.actions.symlink( + output = ctx.outputs.chart_out, + target_file = pkg_info.chart, + ) + + return [DefaultInfo(files = depset(outputs + [ctx.outputs.chart_out]))] + +helm_oci_parts = rule( + implementation = _helm_oci_parts_impl, + doc = "Write the config blob, annotations, tag, destination and layer of the OCI artifact `helm push` creates for a chart.", + attrs = { + "annotations_out": attr.output( + doc = "The manifest annotations, as a JSON object.", + mandatory = True, + ), + "chart": attr.label( + doc = "A helm_chart target providing HelmPackageInfo.", + providers = [HelmPackageInfo], + mandatory = True, + ), + "chart_out": attr.output( + doc = "The chart .tgz, which is the only layer.", + mandatory = True, + ), + "config_out": attr.output( + doc = "The config blob.", + mandatory = True, + ), + "destination_out": attr.output( + doc = "`/`.", + mandatory = True, + ), + "registry_url": attr.string( + doc = "The registry URL given to `helm push`. Mutually exclusive with `registry_url_file`.", + ), + "registry_url_file": attr.label( + doc = "A file containing the registry URL given to `helm push`. Mutually exclusive with `registry_url`.", + allow_single_file = True, + ), + "tag_out": attr.output( + doc = "The tag.", + mandatory = True, + ), + "_oci_parts_tool": attr.label( + executable = True, + cfg = "exec", + default = Label("//helm/private/oci_parts"), + ), + }, +) diff --git a/helm/private/oci_parts/BUILD.bazel b/helm/private/oci_parts/BUILD.bazel new file mode 100644 index 00000000..91839cec --- /dev/null +++ b/helm/private/oci_parts/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test") + +go_library( + name = "oci_parts_lib", + srcs = ["oci_parts.go"], + importpath = "github.com/periareon/rules_helm/helm/private/oci_parts", + visibility = ["//visibility:private"], + deps = ["//helm/private/oci_digest/ociartifact"], +) + +go_binary( + name = "oci_parts", + embed = [":oci_parts_lib"], + visibility = ["//visibility:public"], +) + +go_test( + name = "oci_parts_test", + srcs = ["oci_parts_test.go"], + embed = [":oci_parts_lib"], +) diff --git a/helm/private/oci_parts/oci_parts.go b/helm/private/oci_parts/oci_parts.go new file mode 100644 index 00000000..f912a1a7 --- /dev/null +++ b/helm/private/oci_parts/oci_parts.go @@ -0,0 +1,112 @@ +// oci_parts writes the parts of the OCI artifact that `helm push` creates for a +// chart, in the form `rules_img`'s `image_manifest` and `image_push_spec` read. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "path" + "strings" + + "github.com/periareon/rules_helm/helm/private/oci_digest/ociartifact" +) + +// destination returns the repository `helm push ` pushes +// to, without the scheme. +func destination(registryURL, chartName string) (string, error) { + host, ok := strings.CutPrefix(registryURL, "oci://") + if !ok { + return "", fmt.Errorf("registry URL %q must start with oci://", registryURL) + } + if strings.Trim(host, "/") == "" { + return "", fmt.Errorf("registry URL %q has no host", registryURL) + } + // Helm joins the two the same way (pkg/pusher/ocipusher.go). + return path.Join(host, chartName), nil +} + +// tag returns the tag `helm push` uses for a chart version. OCI tags cannot +// contain `+`, so Helm replaces it with `_`. +func tag(version string) string { + return strings.ReplaceAll(version, "+", "_") +} + +// escapeTemplate makes a value pass through `rules_img`'s Go template expansion +// unchanged. +func escapeTemplate(value string) string { + return strings.ReplaceAll(value, "{{", `{{"{{"}}`) +} + +func readRegistryURL(registryURL, registryURLFile string) (string, error) { + if (registryURL == "") == (registryURLFile == "") { + return "", errors.New("exactly one of -registry_url and -registry_url_file is required") + } + if registryURL != "" { + return registryURL, nil + } + contents, err := os.ReadFile(registryURLFile) + if err != nil { + return "", fmt.Errorf("reading registry URL file: %w", err) + } + url := strings.TrimSpace(string(contents)) + if url == "" { + return "", fmt.Errorf("registry_url_file %q is empty", registryURLFile) + } + return url, nil +} + +func main() { + chartTgz := flag.String("chart", "", "Path to the Helm chart .tgz file") + metadataJSON := flag.String("metadata", "", "Path to the chart metadata JSON file") + registryURL := flag.String("registry_url", "", "Registry URL as given to `helm push`, for example oci://host/path") + registryURLFile := flag.String("registry_url_file", "", "Path to a file containing the registry URL") + configOutput := flag.String("config_output", "", "Path to write the config blob") + annotationsOutput := flag.String("annotations_output", "", "Path to write the manifest annotations as a JSON object") + tagOutput := flag.String("tag_output", "", "Path to write the tag") + destinationOutput := flag.String("destination_output", "", "Path to write `/`") + + flag.Parse() + + if *chartTgz == "" || *metadataJSON == "" || *configOutput == "" || *annotationsOutput == "" || *tagOutput == "" || *destinationOutput == "" { + log.Fatal("Required flags: -chart, -metadata, -config_output, -annotations_output, -tag_output, -destination_output") + } + + url, err := readRegistryURL(*registryURL, *registryURLFile) + if err != nil { + log.Fatal(err) + } + + artifact, err := ociartifact.Load(*chartTgz, *metadataJSON) + if err != nil { + log.Fatalf("Error loading chart: %v", err) + } + + dest, err := destination(url, artifact.Metadata.Name) + if err != nil { + log.Fatal(err) + } + + annotations := make(map[string]string, len(artifact.Annotations)) + for k, v := range artifact.Annotations { + annotations[k] = escapeTemplate(v) + } + annotationsJSON, err := json.Marshal(annotations) + if err != nil { + log.Fatalf("Error marshalling annotations: %v", err) + } + + for path, contents := range map[string][]byte{ + *configOutput: artifact.ConfigBlob, + *annotationsOutput: annotationsJSON, + *tagOutput: []byte(tag(artifact.Metadata.Version)), + *destinationOutput: []byte(dest), + } { + if err := os.WriteFile(path, contents, 0644); err != nil { + log.Fatalf("Error writing %s: %v", path, err) + } + } +} diff --git a/helm/private/oci_parts/oci_parts_test.go b/helm/private/oci_parts/oci_parts_test.go new file mode 100644 index 00000000..454b59bd --- /dev/null +++ b/helm/private/oci_parts/oci_parts_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "text/template" +) + +func TestDestination(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"oci://localhost:5051/charts", "localhost:5051/charts/demo"}, + {"oci://localhost:5051/charts/", "localhost:5051/charts/demo"}, + {"oci://localhost:5051", "localhost:5051/demo"}, + {"oci://registry.example.com/a/b", "registry.example.com/a/b/demo"}, + {"oci://localhost:5051//charts", "localhost:5051/charts/demo"}, + {"oci://localhost:5051/charts//", "localhost:5051/charts/demo"}, + } + for _, tt := range tests { + got, err := destination(tt.url, "demo") + if err != nil { + t.Errorf("destination(%q) error: %v", tt.url, err) + continue + } + if got != tt.want { + t.Errorf("destination(%q) = %q, want %q", tt.url, got, tt.want) + } + } +} + +func TestDestinationRejectsNonOCI(t *testing.T) { + for _, url := range []string{"https://charts.example.com", "localhost:5051/charts", "oci://", "oci:///"} { + if got, err := destination(url, "demo"); err == nil { + t.Errorf("destination(%q) = %q, want an error", url, got) + } + } +} + +func TestTag(t *testing.T) { + for version, want := range map[string]string{ + "0.1.0": "0.1.0", + "0.1.0+build.1": "0.1.0_build.1", + "1.0.0-rc.1+a.b": "1.0.0-rc.1_a.b", + } { + if got := tag(version); got != want { + t.Errorf("tag(%q) = %q, want %q", version, got, want) + } + } +} + +// TestEscapeTemplate expands escaped values the way `rules_img` does and +// expects the original back. +func TestEscapeTemplate(t *testing.T) { + for _, value := range []string{ + "", + "plain", + "{{", + "}}", + "{{ .Values.x }}", + "{{{", + "{{-", + `{{"{{"}}`, + "a {{ b }} c {{", + } { + tmpl, err := template.New("expand").Parse(escapeTemplate(value)) + if err != nil { + t.Errorf("parsing escapeTemplate(%q): %v", value, err) + continue + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, map[string]any{}); err != nil { + t.Errorf("executing escapeTemplate(%q): %v", value, err) + continue + } + if got := buf.String(); got != value { + t.Errorf("expanding escapeTemplate(%q) = %q", value, got) + } + } +} + +func TestReadRegistryURL(t *testing.T) { + file := filepath.Join(t.TempDir(), "url.txt") + if err := os.WriteFile(file, []byte(" oci://localhost/charts\n"), 0644); err != nil { + t.Fatal(err) + } + + if got, err := readRegistryURL("", file); err != nil || got != "oci://localhost/charts" { + t.Errorf("readRegistryURL from file = %q, %v", got, err) + } + if got, err := readRegistryURL("oci://localhost/charts", ""); err != nil || got != "oci://localhost/charts" { + t.Errorf("readRegistryURL from value = %q, %v", got, err) + } + if _, err := readRegistryURL("", ""); err == nil { + t.Error("readRegistryURL with neither set: want an error") + } + if _, err := readRegistryURL("oci://localhost/charts", file); err == nil { + t.Error("readRegistryURL with both set: want an error") + } + + empty := filepath.Join(t.TempDir(), "empty.txt") + if err := os.WriteFile(empty, []byte(" \n"), 0644); err != nil { + t.Fatal(err) + } + // The error quotes the path, which doubles Windows backslashes, so look for the base name. + if _, err := readRegistryURL("", empty); err == nil || !strings.Contains(err.Error(), filepath.Base(empty)) { + t.Errorf("readRegistryURL from an empty file: error = %v, want one naming the file", err) + } +} diff --git a/helm/rules_img.bzl b/helm/rules_img.bzl new file mode 100644 index 00000000..7fcad130 --- /dev/null +++ b/helm/rules_img.bzl @@ -0,0 +1,119 @@ +"""# rules_img integration. + +Rules that build on [rules_img](https://github.com/bazel-contrib/rules_img). +""" + +load("@rules_img//img:image.bzl", "image_manifest") +load("@rules_img//img:layer.bzl", "layer_from_file") +load("@rules_img//img:push.bzl", "image_push_spec") +load("//helm/private:oci_parts.bzl", "helm_oci_parts") + +_HELM_CHART_MEDIA_TYPE = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" +_HELM_CONFIG_MEDIA_TYPE = "application/vnd.cncf.helm.config.v1+json" + +def helm_oci_manifest( + *, + name, + chart, + registry_url = None, + registry_url_file = None, + visibility = None, + **kwargs): + """Describe a chart as the OCI artifact `helm push` creates, for `rules_img`'s `multi_deploy`. + + The target is an `image_manifest` that provides `DeployInfo`, so it can be listed in the + `operations` of a [`multi_deploy`](https://github.com/bazel-contrib/rules_img). Running the + `multi_deploy` pushes the chart to `/:`, as + `helm push` would. Only the chart is pushed; push its images with their own targets. + + ```python + load("@rules_helm//helm:rules_img.bzl", "helm_oci_manifest") + load("@rules_img//img:multi_deploy.bzl", "multi_deploy") + + helm_oci_manifest( + name = "my_chart_oci_manifest", + chart = ":my_chart", + registry_url = "oci://registry.example.com/charts", + ) + + multi_deploy( + name = "deploy", + operations = [":my_chart_oci_manifest"], + ) + ``` + + The pushed manifest is the one `helm push` creates, so its digest is the one + [helm_oci_digest](./helm_oci_digest.md#helm_oci_digest) gives. + + Compared with [helm_push](./helm_push.md#helm_push): + - Only `oci://` registries are supported, so `push_cmd` and plugins such as `cm-push` are not. + - Registry credentials come from `rules_img` (Docker config and credential helpers), not from + `HELM_REGISTRY_USERNAME` and `HELM_REGISTRY_PASSWORD`. + - There is no `include_images`. + + `rules_img` expands Go templates in annotation values. `{{` in a chart's metadata is escaped, + so the annotations match the chart. + + | target | rule | + | --- | --- | + | `{name}` | `image_manifest` | + | `{name}.oci_parts` | config blob, annotations, tag, destination and layer files | + | `{name}.layer` | `layer_from_file` | + | `{name}.push_spec` | `image_push_spec` | + + Args: + name (str): The name of the `image_manifest` target. + chart (Label): A [helm_chart](./helm_chart.md#helm_chart) or [helm_package](./helm_package.md#helm_package) + target. Charts from [helm_import](./helm_import.md#helm_import) are not supported. + registry_url (str, optional): The registry URL given to `helm push`, for example + `oci://registry.example.com/charts`. Mutually exclusive with `registry_url_file`. + registry_url_file (Label, optional): A file containing the registry URL. Mutually + exclusive with `registry_url`. + visibility (list, optional): The visibility of `{name}`. The other targets are private. + **kwargs (dict): Common attributes such as `tags`, `target_compatible_with` and + `testonly`, applied to every target. + """ + helm_oci_parts( + name = name + ".oci_parts", + chart = chart, + registry_url = registry_url, + registry_url_file = registry_url_file, + annotations_out = name + ".oci_parts.annotations.json", + chart_out = name + ".oci_parts.tgz", + config_out = name + ".oci_parts.config.json", + destination_out = name + ".oci_parts.destination.txt", + tag_out = name + ".oci_parts.tag.txt", + visibility = ["//visibility:private"], + **kwargs + ) + + layer_from_file( + name = name + ".layer", + src = name + ".oci_parts.tgz", + media_type = _HELM_CHART_MEDIA_TYPE, + visibility = ["//visibility:private"], + **kwargs + ) + + image_push_spec( + name = name + ".push_spec", + destination_file = name + ".oci_parts.destination.txt", + tag_file = name + ".oci_parts.tag.txt", + visibility = ["//visibility:private"], + **kwargs + ) + + image_manifest( + name = name, + annotations_file = name + ".oci_parts.annotations.json", + config_fragment = name + ".oci_parts.config.json", + config_media_type = _HELM_CONFIG_MEDIA_TYPE, + layers = [name + ".layer"], + push_specs = [name + ".push_spec"], + # `helm push` writes no top-level `mediaType`. + omit_media_type = True, + # A SOCI index would add an annotation `helm push` does not write. + soci = "disabled", + visibility = visibility, + **kwargs + ) diff --git a/tests/helm_oci_manifest/BUILD.bazel b/tests/helm_oci_manifest/BUILD.bazel new file mode 100644 index 00000000..4d28a694 --- /dev/null +++ b/tests/helm_oci_manifest/BUILD.bazel @@ -0,0 +1,158 @@ +load("@bazel_skylib//rules:write_file.bzl", "write_file") +load("@rules_go//go:def.bzl", "go_test") +load("@rules_img//img:multi_deploy.bzl", "multi_deploy") +load("//helm:defs.bzl", "helm_chart") +load("//helm:rules_img.bzl", "helm_oci_manifest") + +EXCLUDE_WINDOWS = select({ + # As in //tests/with_image_deps_img. + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], +}) + +# These charts have no templates, which Helm 4 lint rejects, so linting is +# disabled for them as in //tests/no_templates. +helm_chart( + name = "simple", + chart_json = json.encode({ + "apiVersion": "v2", + "appVersion": "1.16.0", + "description": "A Helm chart for Kubernetes", + "icon": "https://helm.sh/img/helm.svg", + "name": "simple", + "type": "application", + "version": "0.1.0", + }), + tags = ["no-helm-lint"], + values_json = json.encode({}), +) + +# A description that `rules_img` would otherwise expand as a Go template. +helm_chart( + name = "template", + chart_json = json.encode({ + "apiVersion": "v2", + "description": "Renders {{ .Values.name }} and a lone {{", + "name": "template", + "version": "0.1.0", + }), + tags = ["no-helm-lint"], + values_json = json.encode({}), +) + +# A version with build metadata, whose `+` is not valid in a tag. +helm_chart( + name = "plus", + chart_json = json.encode({ + "apiVersion": "v2", + "name": "plus", + "version": "0.1.0+build.1", + }), + tags = ["no-helm-lint"], + values_json = json.encode({}), +) + +helm_oci_manifest( + name = "simple.oci_manifest", + chart = ":simple", + registry_url = "oci://localhost:5051/charts", + target_compatible_with = EXCLUDE_WINDOWS, +) + +helm_oci_manifest( + name = "template.oci_manifest", + chart = ":template", + registry_url = "oci://localhost:5051/charts", + target_compatible_with = EXCLUDE_WINDOWS, +) + +helm_oci_manifest( + name = "plus.oci_manifest", + chart = ":plus", + registry_url = "oci://localhost:5051/charts", + target_compatible_with = EXCLUDE_WINDOWS, +) + +multi_deploy( + name = "deploy", + operations = [ + ":plus.oci_manifest", + ":simple.oci_manifest", + ":template.oci_manifest", + ], + target_compatible_with = EXCLUDE_WINDOWS, +) + +helm_oci_manifest( + name = "simple.oci_manifest_trailing_slash", + chart = ":simple", + registry_url = "oci://localhost:5051/charts/", + target_compatible_with = EXCLUDE_WINDOWS, +) + +multi_deploy( + name = "deploy_trailing_slash", + operations = [":simple.oci_manifest_trailing_slash"], + target_compatible_with = EXCLUDE_WINDOWS, +) + +write_file( + name = "registry_url_file", + out = "registry_url.txt", + content = [ + "oci://localhost:5051/charts", + "", + ], +) + +helm_oci_manifest( + name = "simple.oci_manifest_url_file", + chart = ":simple", + registry_url_file = ":registry_url_file", + target_compatible_with = EXCLUDE_WINDOWS, +) + +multi_deploy( + name = "deploy_url_file", + operations = [":simple.oci_manifest_url_file"], + target_compatible_with = EXCLUDE_WINDOWS, +) + +# The `simple.helm_push_*.golden` files are what a real `helm push` of +# `:simple` sends. To regenerate them after `:simple`'s .tgz changes, run +# `crane registry serve --address localhost:5051`, set the .tgz mtime to the +# epoch, run `TZ=UTC helm push simple-0.1.0.tgz oci://localhost:5051/golden +# --plain-http`, and save `crane manifest localhost:5051/golden/simple:0.1.0` +# and `crane blob` of its config digest, both without a trailing newline. +go_test( + name = "helm_oci_manifest_test", + srcs = ["helm_oci_manifest_test.go"], + data = [ + "simple.helm_push_config.golden", + "simple.helm_push_manifest.golden", + ":deploy", + ":deploy_trailing_slash", + ":deploy_url_file", + ":plus", + ":plus.oci_digest", + ":simple", + ":simple.oci_digest", + ":template", + ":template.oci_digest", + ], + env = { + "DEPLOY": "$(rlocationpath :deploy)", + "DEPLOY_TRAILING_SLASH": "$(rlocationpath :deploy_trailing_slash)", + "DEPLOY_URL_FILE": "$(rlocationpath :deploy_url_file)", + "HELM_PUSH_CONFIG": "$(rlocationpath simple.helm_push_config.golden)", + "HELM_PUSH_MANIFEST": "$(rlocationpath simple.helm_push_manifest.golden)", + "PLUS_CHART": "$(rlocationpath :plus)", + "PLUS_DIGEST": "$(rlocationpath :plus.oci_digest)", + "SIMPLE_CHART": "$(rlocationpath :simple)", + "SIMPLE_DIGEST": "$(rlocationpath :simple.oci_digest)", + "TEMPLATE_CHART": "$(rlocationpath :template)", + "TEMPLATE_DIGEST": "$(rlocationpath :template.oci_digest)", + }, + target_compatible_with = EXCLUDE_WINDOWS, + deps = ["@rules_go//go/runfiles"], +) diff --git a/tests/helm_oci_manifest/helm_oci_manifest_test.go b/tests/helm_oci_manifest/helm_oci_manifest_test.go new file mode 100644 index 00000000..d848bcab --- /dev/null +++ b/tests/helm_oci_manifest/helm_oci_manifest_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/bazelbuild/rules_go/go/runfiles" +) + +const ( + helmConfigMediaType = "application/vnd.cncf.helm.config.v1+json" + helmChartMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" +) + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Annotations map[string]string `json:"annotations"` +} + +type manifest struct { + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` + Annotations map[string]string `json:"annotations"` +} + +// pushed is one chart that a `multi_deploy` wrote to the sink. +type pushed struct { + raw []byte + manifest manifest + layout string +} + +func (p pushed) blob(t *testing.T, digest string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(p.layout, "blobs", strings.Replace(digest, ":", "/", 1))) + if err != nil { + t.Fatalf("Failed to read blob %s: %v", digest, err) + } + return data +} + +func readRunfile(t *testing.T, envVar string) []byte { + t.Helper() + path, err := runfiles.Rlocation(os.Getenv(envVar)) + if err != nil { + t.Fatalf("Failed to locate %s: %v", envVar, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read %s: %v", envVar, err) + } + return data +} + +func sha256Digest(data []byte) string { + h := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(h[:]) +} + +// deploy runs a `multi_deploy` with an OCI layout sink, which needs no +// registry, and returns what it wrote keyed by image reference. +func deploy(t *testing.T, envVar string) map[string]pushed { + t.Helper() + + launcher, err := runfiles.Rlocation(os.Getenv(envVar)) + if err != nil { + t.Fatalf("Failed to locate %s: %v", envVar, err) + } + runfilesEnv, err := runfiles.Env() + if err != nil { + t.Fatal(err) + } + layout := filepath.Join(t.TempDir(), "layout") + + cmd := exec.Command(launcher, "--sink", "oci:"+layout) + cmd.Env = append(os.Environ(), runfilesEnv...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s failed: %v\n%s", envVar, err, out) + } + + var index struct { + Manifests []descriptor `json:"manifests"` + } + indexJSON, err := os.ReadFile(filepath.Join(layout, "index.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(indexJSON, &index); err != nil { + t.Fatal(err) + } + + result := map[string]pushed{} + for _, d := range index.Manifests { + p := pushed{layout: layout} + p.raw = p.blob(t, d.Digest) + if err := json.Unmarshal(p.raw, &p.manifest); err != nil { + t.Fatal(err) + } + result[d.Annotations["io.containerd.image.name"]] = p + } + return result +} + +// checkChart compares a pushed chart with the chart .tgz and the digest +// helm_oci_digest predicts for `helm push`. +func checkChart(t *testing.T, p pushed, chartEnv, digestEnv string) { + t.Helper() + + if got := p.manifest.Config.MediaType; got != helmConfigMediaType { + t.Errorf("config mediaType = %q, want %q", got, helmConfigMediaType) + } + if len(p.manifest.Layers) != 1 { + t.Fatalf("got %d layers, want 1", len(p.manifest.Layers)) + } + if got := p.manifest.Layers[0].MediaType; got != helmChartMediaType { + t.Errorf("layer mediaType = %q, want %q", got, helmChartMediaType) + } + if !bytes.Equal(p.blob(t, p.manifest.Layers[0].Digest), readRunfile(t, chartEnv)) { + t.Errorf("layer is not the chart .tgz") + } + + if got, want := sha256Digest(p.raw), string(readRunfile(t, digestEnv)); got != want { + t.Errorf("manifest has digest %s, helm_oci_digest predicts %s: %s", got, want, p.raw) + } +} + +func TestDeploy(t *testing.T) { + charts := deploy(t, "DEPLOY") + if len(charts) != 3 { + t.Fatalf("got references %v, want 3", charts) + } + + simple, ok := charts["localhost:5051/charts/simple:0.1.0"] + if !ok { + t.Fatalf("simple is missing from %v", charts) + } + checkChart(t, simple, "SIMPLE_CHART", "SIMPLE_DIGEST") + if !bytes.Equal(simple.blob(t, simple.manifest.Config.Digest), readRunfile(t, "HELM_PUSH_CONFIG")) { + t.Errorf("config blob differs from the one `helm push` sent") + } + + if want := readRunfile(t, "HELM_PUSH_MANIFEST"); !bytes.Equal(simple.raw, want) { + t.Errorf("manifest = %s\nwant %s", simple.raw, want) + } + + template, ok := charts["localhost:5051/charts/template:0.1.0"] + if !ok { + t.Fatalf("template is missing from %v", charts) + } + checkChart(t, template, "TEMPLATE_CHART", "TEMPLATE_DIGEST") + if got, want := template.manifest.Annotations["org.opencontainers.image.description"], "Renders {{ .Values.name }} and a lone {{"; got != want { + t.Errorf("description annotation = %q, want %q", got, want) + } + + plus, ok := charts["localhost:5051/charts/plus:0.1.0_build.1"] + if !ok { + t.Fatalf("plus is missing from %v", charts) + } + checkChart(t, plus, "PLUS_CHART", "PLUS_DIGEST") +} + +// TestRegistryURLForms expects a trailing slash and `registry_url_file` to +// push the same manifest to the same reference as a plain `registry_url`. +func TestRegistryURLForms(t *testing.T) { + const ref = "localhost:5051/charts/simple:0.1.0" + want := deploy(t, "DEPLOY")[ref].raw + + for _, envVar := range []string{"DEPLOY_TRAILING_SLASH", "DEPLOY_URL_FILE"} { + charts := deploy(t, envVar) + p, ok := charts[ref] + if !ok || len(charts) != 1 { + t.Errorf("%s pushed %v, want only %s", envVar, charts, ref) + continue + } + if !bytes.Equal(p.raw, want) { + t.Errorf("%s manifest = %s\nwant %s", envVar, p.raw, want) + } + } +} diff --git a/tests/helm_oci_manifest/simple.helm_push_config.golden b/tests/helm_oci_manifest/simple.helm_push_config.golden new file mode 100644 index 00000000..f1ac4931 --- /dev/null +++ b/tests/helm_oci_manifest/simple.helm_push_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/helm_oci_manifest/simple.helm_push_manifest.golden b/tests/helm_oci_manifest/simple.helm_push_manifest.golden new file mode 100644 index 00000000..e8ee8557 --- /dev/null +++ b/tests/helm_oci_manifest/simple.helm_push_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:90935b2f892f071447670dc79fa12d20eabc082cf6a206168f20a066dee6f0e4","size":294}],"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