Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to avoid this being a mandatory dependency?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not with the existing macro in this module. Bazel doesn't have any concept of optional deps.

Few options that I can think of:

  1. Separate rules_helm_img module in this repo (e.g in module/rules_helm_img). Needs a second BCR module and release workflow change. Looks like rules_img published img_tool like this.
  2. Have the caller pass the rules in. Should work but a bit clunky and a weird API
  3. Ship just helm_oci_parts and then document 30 or so lines or rules_img wiring to copy. Also clunky.

If we really want to avoid this dep in the root module, I think 1. is probably the least worst option.

LMK what you think!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abrisco I've opened a speculative PR demonstrating 1 here: rdark#2


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)
Expand Down
10 changes: 10 additions & 0 deletions docs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -183,6 +192,7 @@ mdbook(
":helm_toolchain",
":k8s_defs",
":k8s_extensions",
":rules_img",
":settings",
],
book = "book.toml",
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/src/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions helm/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ exports_files(
"helm_push.bzl",
"helm_template.bzl",
"helm_toolchain.bzl",
"rules_img.bzl",
],
visibility = ["//visibility:public"],
)
Expand Down Expand Up @@ -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",
],
)
8 changes: 1 addition & 7 deletions helm/private/oci_digest/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
2 changes: 1 addition & 1 deletion helm/private/oci_digest/chartmeta/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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__"],
)
188 changes: 6 additions & 182 deletions helm/private/oci_digest/oci_digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-name>/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")
Expand All @@ -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)
}
Expand All @@ -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)
}
}
Expand Down
21 changes: 21 additions & 0 deletions helm/private/oci_digest/ociartifact/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
Loading
Loading