Skip to content
Closed
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
31 changes: 31 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,34 @@ use_repo(
"rules_helm_test_img_container_base",
"rules_helm_test_oci_container_base",
)

helm_charts = use_extension(
"@rules_helm//helm:extensions.bzl",
"helm_charts",
dev_dependency = True,
)
helm_charts.chart(
name = "helm_charts_test_cert_manager",
url = "oci://quay.io/jetstack/charts/cert-manager:1.19.4",
)
helm_charts.chart(
name = "helm_charts_test_cert_manager_pinned",
sha256 = "d0c202f57b8653eb957fb5b2f9472c1b11cf6b51acc5c777e4b824f7b5a9ea87",
url = "oci://quay.io/jetstack/charts/cert-manager:1.19.4",
)
helm_charts.chart(
name = "helm_charts_test_pre_release",
sha256 = "0041fc0111fab7d62beed6d46d283f5ef4d851ad8c77055466bb4c82e36b6a6d",
url = "oci://quay.io/jetstack/charts/cert-manager:1.21.0-beta.0",
)
helm_charts.chart(
name = "helm_charts_test_vault_operator",
url = "oci://ghcr.io/bank-vaults/helm-charts/vault-operator:1.23.0",
)
use_repo(
helm_charts,
"helm_charts_test_cert_manager",
"helm_charts_test_cert_manager_pinned",
"helm_charts_test_pre_release",
"helm_charts_test_vault_operator",
)
106 changes: 106 additions & 0 deletions helm/extensions.bzl
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Bzlmod extensions"""

load("//helm/private:chart_utils.bzl", "chart_utils")
load("//helm/private:extension_utils.bzl", "find_modules")
load("//helm/private:import.bzl", "helm_import_repository")
load("//helm/private:import_authn.bzl", "authn")
load("//helm/private:oci_chart.bzl", "oci_chart")
load(
"//helm/private:repositories.bzl",
"helm_host_alias_repository",
Expand Down Expand Up @@ -186,6 +190,45 @@ def _helm_impl(module_ctx):
reproducible = True,
)

def _lookup_sha256(module_ctx, state, url):
if url not in state["sha256"]:
if "au" not in state:
state["au"] = authn.new(module_ctx)
state["sha256"][url] = oci_chart.resolve_sha256(module_ctx, state["au"], url)
return state["sha256"][url]

def _helm_charts_impl(module_ctx):
# Digests persisted in the lockfile by a previous evaluation (Bazel 8.5+).
facts = getattr(module_ctx, "facts", {})
used_facts = {}
state = {"sha256": {}}
lookup = lambda url: _lookup_sha256(module_ctx, state, url)

for mod in module_ctx.modules:
for tag in mod.tags.chart:
if not chart_utils.is_chart_url(tag.url):
fail("helm_charts.chart(name = \"{}\") needs a url of the form `oci://<host>/<path>:<version>`, got `{}`".format(tag.name, tag.url))
if tag.sha256 and not chart_utils.is_sha256(tag.sha256):
fail("helm_charts.chart(name = \"{}\") needs a `sha256` of 64 lower case hexadecimal digits, got `{}`".format(tag.name, tag.sha256))

chart = chart_utils.chart_sha256(tag.sha256, tag.url, facts, lookup)
if chart.is_fact:
used_facts[tag.url] = chart.sha256

helm_import_repository(
name = tag.name,
url = tag.url,
sha256 = chart.sha256,
)

# Without facts, a looked-up digest is kept only if the extension is not reproducible:
# the lockfile then records the repositories, and with them the digests.
kwargs = {"reproducible": hasattr(module_ctx, "facts") or not used_facts}
if hasattr(module_ctx, "facts"):
kwargs["facts"] = used_facts

return module_ctx.extension_metadata(**kwargs)

_host_tools = tag_class(
doc = """\
An extension for creating a host alias repository that provides a shorter name for the host platform's helm binary.
Expand Down Expand Up @@ -307,6 +350,53 @@ register_toolchains("@with_diff_toolchains//:all")
},
)

_chart = tag_class(
doc = """\
Fetch a Helm chart from an OCI registry with `helm_import_repository`.

The repository holds a `helm_import` target named after the chart, which is the last \
path segment of `url` without the version. Refer to it as `@<name>//:<chart>`. The \
shorthand `@<name>` does not resolve to the chart.

If `sha256` is unset, the extension reads the digest of the chart package from the \
registry manifest, and trusts the registry for it on first use. On Bazel 8.5 and newer \
the digest is stored as a fact in `MODULE.bazel.lock`, keyed by `url`, so later \
evaluations make no registry request for it. Older Bazel versions record the digest in \
the lockfile as part of the repository. Without a lockfile, the digest is looked up \
whenever the extension is evaluated, and a version tag that the registry moves gives a \
different chart. Set `sha256` to pin the digest in `MODULE.bazel`.

A stored digest is not checked against the registry. If the registry replaces the \
chart behind a version tag, the fetch fails with a checksum mismatch. Delete the \
entry for `url` from the `facts` section of `MODULE.bazel.lock` to look the digest \
up again.

```python
helm_charts = use_extension("@rules_helm//helm:extensions.bzl", "helm_charts")
helm_charts.chart(
name = "karpenter",
url = "oci://public.ecr.aws/karpenter/karpenter:1.9.0",
)
use_repo(helm_charts, "karpenter")

# Then you can use @karpenter//:karpenter as a dependency in your BUILD files
```
""",
attrs = {
"name": attr.string(
doc = "The name of the repository.",
mandatory = True,
),
"sha256": attr.string(
doc = "The expected SHA-256 hash of the chart package. If unset, the extension looks it up in the registry.",
),
"url": attr.string(
doc = "The chart url, in the form `oci://<host>/<path>:<version>`.",
mandatory = True,
),
},
)

helm = module_extension(
implementation = _helm_impl,
tag_classes = {
Expand All @@ -315,3 +405,19 @@ helm = module_extension(
"toolchain": _toolchain,
},
)

helm_charts = module_extension(
doc = """\
Module extension for fetching Helm charts from OCI registries.

Charts live in their own extension, so an unreachable registry does not stop the `helm` \
extension from resolving toolchains. All tags are resolved in one evaluation, so a \
lookup that fails (an unknown version or an unreachable registry) fails every \
repository from this extension. Set `sha256` to skip the lookup. Tags from every module \
are used, so repository names must be unique across modules.
""",
implementation = _helm_charts_impl,
tag_classes = {
"chart": _chart,
},
)
93 changes: 93 additions & 0 deletions helm/private/chart_utils.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Helpers for importing Helm charts that need no repository or module context."""

def _is_sha256(value):
"""Check that a value is a hex-encoded sha256 digest in lower case.

Args:
value (object): The value to check.

Returns:
bool: True if the value is a string of 64 lower case hex digits.
"""
return type(value) == "string" and len(value) == 64 and not value.strip("0123456789abcdef")

def _is_chart_url(url):
"""Check that a url has the form `oci://<host>/<path>:<version>`.

Args:
url (str): The url to check.

Returns:
bool: True if the url has that form.
"""
for char in url.elems():
if char.isspace():
return False

url_without_version, _, version = url.rpartition(":")
hostname, _, path = url_without_version.removeprefix("oci://").partition("/")
return bool(
url.startswith("oci://") and
hostname and
path and
"" not in path.split("/") and
version and
"/" not in version and
":" not in path and
"@" not in path,
)

def _starts_with_version(text):
"""Check that text starts like a version: an optional `v`, then `<digits>.<digit>`."""
major, dot, rest = text.removeprefix("v").partition(".")
return bool(dot and major.isdigit() and rest[:1].isdigit())

def _split_chart_file(chart_file):
"""Split a chart file name into the chart name and the version.

A chart file is named `<name>-<version>.tgz`. Both parts can contain a dash,
so the version starts at the first dash that is followed by a version number.

Args:
chart_file (str): The chart file name.

Returns:
tuple: The chart name and the chart version.
"""
stem = chart_file.removesuffix(".tgz")
parts = stem.split("-")
for i in range(1, len(parts)):
if _starts_with_version(parts[i]):
return "-".join(parts[:i]), "-".join(parts[i:])

# No version number: split at the last dash.
name, _, version = stem.rpartition("-")
return name, version

def _chart_sha256(pinned, url, facts, lookup):
"""Choose the sha256 for a chart from a tag, the stored facts or the registry.

Args:
pinned (str): The sha256 set in the tag, or an empty string.
url (str): The chart url.
facts (dict or Facts): The digests stored by a previous evaluation, by chart url. Only `get` is called on it.
lookup (callable): Called with the chart url, returns the sha256 from the registry.

Returns:
struct: The `sha256` to use, and `is_fact`, which is True when the digest
did not come from the tag and so belongs in the stored facts.
"""
if pinned:
return struct(sha256 = pinned, is_fact = False)

sha256 = facts.get(url)
if not _is_sha256(sha256):
sha256 = lookup(url)
return struct(sha256 = sha256, is_fact = True)

chart_utils = struct(
chart_sha256 = _chart_sha256,
is_chart_url = _is_chart_url,
is_sha256 = _is_sha256,
split_chart_file = _split_chart_file,
)
Loading