rules_k8s
Kubernetes CRDs as build artifacts: controller-gen as a real Bazel action (no host Go, no `go list`, no module cache, no `bazel query`), plus manifest bundle/validate and operator images.
| Latest | 0.0.3 |
|---|---|
| Versions | 1 |
| Category | Bazel rules |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_k8s/ |
| Source | github.com/tomato-bazel/rules_k8s |
bazel_dep(name = "rules_k8s", version = "0.0.3")
View source & releases on GitHub ↗
Kubernetes CRDs as build artifacts. k8s_crd_library runs controller-tools as a
real Bazel action — sandboxed, cached, remotable — with no host Go, no go list, no module cache, no network, and no bazel query.
That combination is supposed to be impossible, which is why every operator in the
fleet regenerates CRDs out of band and commits the result. It isn’t impossible:
go/packages accepts a custom driver, and inside a rule we already know the
package graph, because rules_go’s go_pkg_info_aspect emits it as declared
outputs. rules_k8s ships the small driver that closes the loop.
The payoff is that CRD drift stops being something you gate and starts being something that cannot happen.
//crd/ k8s_crd_library · the CRD toolchain · K8sCrdInfo
//crd/private/ driver/ (the go/packages driver) · gen/ (controller-tools, as a library)
//k8s/ k8s_object · k8s_bundle · k8s_validate · k8s_diff
//k8s/private/ bundle/ (the aggregator) · schemas.bzl (the aspect)
//validate/ the kubeconform toolchain · crd2jsonschema
//kubectl/ the kubectl toolchain (PATH default + escape hatch)
//oci/ k8s_operator_image (+ the platform transition)
//docs/ stardoc reference, generated + committed + gated
//examples/smoke/ the end-to-end proof
Status: v0.0.2
k8s_crd_library works and is verified byte-identical to stock
controller-gen v0.16.5, including against every CRD of a real operator.
k8s_object / k8s_bundle / k8s_validate / k8s_diff work.
k8s_operator_image builds a linux/amd64 operator image on any host with no
--platforms flag, and its _push target actually works.
Why this exists
The pipeline in every operator repo today is:
Go type + markers → controller-gen → operator/config/crd/*.yaml (committed)
→ [ an unautomated human `cp` ]
→ helm/<chart>/crds/*.yaml (committed, byte-duplicated)
Nothing in any BUILD file, script, or workflow performs that cp. It goes stale. A stale chart CRD is not a build error: ArgoCD
renders the chart’s crds/, server-side-applies the older schema over the live
CRD, and the API server then structurally prunes the now-unknown fields off live
custom resources. Silent data loss, from a copy nobody made.
With CRDs as a build artifact, the duplicate is generated, so it cannot go stale:
load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files")
load("@rules_k8s//crd:defs.bzl", "k8s_crd_library")
k8s_crd_library(
name = "crds",
deps = ["//api/v1:v1"], # a plain rules_go go_library
group = "platform.example.com", # verified against the output, so it can't rot
)
# `bazel run //crd:update` regenerates both trees; `bazel test` gates them.
write_source_files(
name = "update",
files = {
"//operator/config/crd": ":crds",
"//helm/my-operator/crds": ":crds", # <- this line replaces the human `cp`
},
)
A whole-directory sync catches all three ways the hand-copy fails: a stale file,
a new CRD nobody remembered to copy, and an orphan left behind. A per-file
diff_test catches only the first.
How it works
controller-tools asks go/packages for metadata only:
// controller-tools/pkg/loader/loader.go
l.cfg.Mode |= packages.NeedName | packages.NeedFiles |
packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedTypesSizes
No NeedTypes, no NeedSyntax — it type-checks itself from CompiledGoFiles.
So go/packages is a pure metadata channel here, and a driver that serves file
paths is a complete answer. //crd/private/driver is that driver: it reads the
pkg.json files go_pkg_info_aspect already produced and never consults the go
command, the network, the module cache, or Bazel.
rules_go’s own gopackagesdriver can’t be used directly — it shells out to
bazel query, and you cannot run Bazel inside a Bazel action. Its
manifest-reading half can be, so that half is vendored (Apache-2.0) and the
bazel query half is dropped. See //crd/private/driver/BUILD.bazel.
Install
.bazelrc:
common --registry=https://registry.fastverk.com/
common --registry=https://bcr.bazel.build/
# Register the CRD toolchain. This goes in .bazelrc and NOT MODULE.bazel:
# register_toolchains() propagates to every consumer, which would drag a Go SDK
# into the Rust services that only ever READ CRD schemas.
common --extra_toolchains=@rules_k8s//crd:default_k8s_crd_toolchain
MODULE.bazel:
bazel_dep(name = "rules_k8s", version = "0.0.2")
# Only repos that actually RUN controller-gen need these.
bazel_dep(name = "rules_go", version = "0.60.0")
bazel_dep(name = "gazelle", version = "0.51.0")
Rules
| Rule | Kind | What it does |
|---|---|---|
k8s_crd_library | rule | go_library deps → CRD YAML as a declared TreeArtifact. Emits K8sCrdInfo. Optional post_processors. |
k8s_object | rule | Adopt checked-in manifests into the graph; optionally assert their Kind/apiVersion. |
k8s_bundle | rule | Collect manifests into one directory, failing on duplicate group/Kind/namespace/name. |
k8s_validate | test | kubeconform a bundle against CRD schemas resolved through its deps. |
k8s_diff | executable | bazel run — diff a bundle against a live cluster. Read-only. |
k8s_operator_image | macro | An operator binary → a linux/amd64 image, via a per-target transition. Emits <name>, <name>_push, <name>_tarball. |
k8s_crd_toolchain | rule | Declares a CRD codegen toolchain (controller-gen + driver). |
Why k8s_operator_image exists
An operator image needs a linux/amd64 binary regardless of the host. The fleet does that with an invocation-wide flag:
build:operator-image --platforms=@rules_go//go/toolchain:linux_amd64
That retargets everything the invocation touches, including the tools rules_oci
pulls in to push — which then want a cpp toolchain for a platform that has none.
So one such repo’s CI does not use its own oci_push at all. From its image
workflow:
“image_push pulls the crane push tool into the build graph, and under
--config=operator-image(--platforms=go/linux_amd64) that tool’s launcher can’t resolve a cpp toolchain (No matching toolchains for cpp:toolchain_type).”
:image_push ends up dead code, and pushing falls back to a hand-rolled crane.
k8s_operator_image puts the retarget on the target: only the image (and
everything under it — base, layers, binary) lands on linux/amd64, while oci_push
stays in the host configuration where its launcher resolves normally. So a plain
go_binary — no goos, no goarch, no pure = "on", no second
cross-compiled target beside the real one — becomes a correct image:
k8s_operator_image(
name = "my-operator-image",
binary = "//operator/cmd:manager",
repository = "ghcr.io/example/my-operator",
)
Verified on an arm64 Mac with no flags: the shipped binary is
ELF 64-bit LSB executable, x86-64, statically linked, and
:operator-image_push builds. //examples/smoke/cmd:image_is_linux_amd64_test
keeps it that way — a transition that stops firing still builds and still pushes,
it just ships a binary that cannot exec.
What k8s_validate does not check
It is worth being blunt, because a validator people over-trust is worse than none:
- CEL is not evaluated.
x-kubernetes-validations(self == oldSelf) can reference an object’s prior state, which does not exist at build time. - Policy is not checked. A field can be well-typed and still wrong — an
ArgoCD
Applicationpinned to an unmerged feature branch is a valid string. That needs a policy engine, which rules_k8s deliberately does not ship (no consumer has asked for one, and a speculative seam that nobody walks through is worse than an honest gap). - Core Kubernetes kinds need
core_schemas. They have no CRD to derive a schema from. There is no network fallback on purpose: kubeconform’s own-schema-location defaultis a raw GitHub URL, which would make every validation a network fetch and fail on RBE.
Testing
| Target | What it proves |
|---|---|
//examples/smoke:crds_match_stock_controller_gen | The generated CRD is byte-identical to stock controller-gen v0.16.5. |
//examples/smoke:crds_listing_test | No Kind silently appeared or vanished. |
//crd/private/driver:driver_test | The driver serves a graph from pkg.json; pkg.Name is backfilled; missing inputs fail loudly rather than yielding an empty graph. |
Verifying against stock controller-gen
examples/smoke/testdata/expected_widgets.yaml is not this rule’s output
blessed into a file — it is the verbatim output of stock controller-gen:
go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5 \
crd paths=./examples/smoke/api/v1/... output:crd:artifacts:config=/tmp/stockcrd
diff /tmp/stockcrd/smoke.rules-k8s.dev_widgets.yaml \
bazel-bin/examples/smoke/crds/smoke.rules-k8s.dev_widgets.yaml
Regenerate the golden only from stock controller-gen, never from this rule.
This matters because a real fleet has its CRDs committed to git and rendered into
charts that a GitOps controller server-side-applies. If output drifted from stock
by one line, adopting rules_k8s would rewrite every schema and push the rewrite
live. It has already caught one such instance: a Bazel-built binary has no
debug.ReadBuildInfo(), so controller-tools stamped
controller-gen.kubebuilder.io/version: (unknown) until //crd/private/gen’s
x_defs supplied the version.
The version pins in //:go.mod are schema decisions, not dependency bumps — read
the comment there before touching them.
Proving it is really hermetic
The claim is only worth anything if it fails when it should:
env PATH=<bazel-only-dir>:/usr/bin:/bin GOROOT=/nonexistent \
GOMODCACHE=/nonexistent GOPROXY=off \
bazel build //examples/smoke:crds --disk_cache= --spawn_strategy=sandboxed
If go is reachable, the control is invalid — check with command -v go first.
Without the driver, controller-tools fails with go command required, not found.
Docs
//docs holds stardoc reference, generated from the .bzl docstrings and
committed, with a gate: change a rule without running bazel run //docs:update
and bazel test //docs:all fails. Same generated-committed-gated shape this
module argues for everywhere else.
Covered: //crd:providers.bzl, //crd:toolchains.bzl, //k8s:defs.bzl.
Two gaps, both blocked upstream — stardoc needs a bzl_library for every
transitively loaded module, and a file you cannot see is a file you cannot wrap:
//crd:defs.bzlloads@rules_go//go/tools/gopackagesdriver:aspect.bzl, which rules_go neither wraps nor exports (Visibility error). Vendoring the aspect doesn’t help either: it readsGoStdLib, which@rules_go//go:def.bzldoesn’t export, and providers are identity-based — a copied definition is a different provider, so a vendored aspect couldn’t read it off a rules_go target.//oci:defs.bzlloads@rules_pkg//pkg:tar.bzl, which is public, but whose loads reach@rules_pkg//pkg/private:util.bzl— exported only to rules_pkg’s own packages. (@rules_oci//oci:defsdoes ship abzl_library; rules_oci is not the problem.)
Both rules are documented in this README and in their own docstrings; only the
generated page is missing. Stub bzl_library targets over someone else’s private
modules would rot silently against the upstream file — worse than an honest gap.
Compatibility
- Bazel 7+ (bzlmod).
- rules_go 0.60.0.
//crd/private/drivervendors files from rules_go’sgo/tools/gopackagesdriver, and the rule loadsgo_pkg_info_aspectfrom it — both are rules_go internals with no compatibility promise. On a rules_go bump, re-diff the vendored files and re-run the smoke test. - controller-tools v0.16.5.
License
MIT. //crd/private/driver contains files vendored from rules_go under
Apache-2.0; each retains its original header.
Usage#
Real usage, taken from the module’s examples/.
examples/smoke/BUILD.bazel
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("@rules_shell//shell:sh_test.bzl", "sh_test")
load("//crd:defs.bzl", "k8s_crd_library")
load("//k8s:defs.bzl", "k8s_bundle", "k8s_validate")
# The end-to-end proof: kubebuilder markers -> CRD YAML, as a sandboxed action.
#
# `bazel build //examples/smoke:crds` must succeed with no Go on PATH, no module
# cache, and no network. If it ever needs any of those, the ruleset does not work
# and this target is how you find out.
#
# The API package deliberately exercises what a naive driver gets wrong:
# cross-package types (metav1.Condition), a stdlib-typed field (metav1.Time wraps
# time.Time — the aspect's Imports map omits stdlib edges), validation markers,
# and a nested struct.
k8s_crd_library(
name = "crds",
group = "smoke.rules-k8s.dev",
visibility = ["//visibility:public"],
deps = ["//examples/smoke/api/v1:v1"],
)
# THE test of this whole ruleset.
#
# `testdata/expected_widgets.yaml` is not our own output blessed into a file — it
# is the verbatim output of STOCK `controller-gen v0.16.5` over the same package
# (`go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5 crd
# paths=./examples/smoke/api/v1/...`). So this asserts equivalence with upstream,
# not self-consistency.
#
# That matters because a fleet has its CRDs committed to git and rendered into
# charts that ArgoCD server-side-applies. If this rule's output drifted from
# stock by so much as a line, adopting it would rewrite every schema and push
# the rewrite live. It has already caught one real instance: a Bazel-built binary
# has no debug.ReadBuildInfo(), so controller-tools stamped
# `controller-gen.kubebuilder.io/version: (unknown)` until //crd/private/gen's
# x_defs supplied the version.
#
# Regenerate ONLY from stock controller-gen, never from this rule's own output.
diff_test(
name = "crds_match_stock_controller_gen",
file1 = "testdata/expected_widgets.yaml",
file2 = ":widgets_yaml",
)
genrule(
name = "widgets_yaml",
srcs = [":crds"],
outs = ["widgets_actual.yaml"],
cmd = "cp $(location :crds)/smoke.rules-k8s.dev_widgets.yaml $@",
)
# The listing rides in an output group so a write_source_files over :crds copies
# only CRD YAML. Golden-testing it turns "a Kind silently vanished from the CRD
# set" into a legible one-line failure.
filegroup(
name = "crds_listing",
srcs = [":crds"],
output_group = "listing",
)
diff_test(
name = "crds_listing_test",
file1 = ":crds_listing",
file2 = "testdata/expected_listing.txt",
)
# ── the manifest half: bundle + validate ──────────────────────────────────────
k8s_bundle(
name = "manifests",
srcs = glob(["manifests/*.yaml"]),
# `deps` contributes SCHEMAS, not manifests: the bundle holds the Widget,
# and k8s_validate resolves its schema from the CRD without a hand-written list.
deps = [":crds"],
)
# Proves the whole chain: Go markers -> CRD (hermetic action) -> JSON Schema ->
# kubeconform -> a CR checked against a schema generated in the same build. The
# ConfigMap alongside the Widget proves core and custom schemas resolve together.
k8s_validate(
name = "manifests_valid",
bundle = ":manifests",
# block-network is the assertion, not a precaution: kubeconform's own
# "default" schema location is a raw GitHub URL, so it would be very easy to
# ship a validate that silently fetches schemas at build time and dies on
# RBE. If this rule ever regains a network dep, this test fails here first.
tags = ["block-network"],
)
filegroup(
name = "manifests_listing",
srcs = [":manifests"],
output_group = "listing",
)
diff_test(
name = "manifests_listing_test",
file1 = ":manifests_listing",
file2 = "testdata/expected_manifests_listing.txt",
)
# A validator that only ever passes is not a test. This bundle holds a Widget
# with replicas=1000 (marker says Maximum=99) and mode=Sideways (not in the
# Enum); //examples/smoke:validate_rejects_bad_test asserts k8s_validate FAILS on
# it. Not a k8s_validate target itself — it is expected to fail.
k8s_bundle(
name = "badmanifests",
srcs = glob(["badmanifests/*.yaml"]),
deps = [":crds"],
)
k8s_validate(
name = "badmanifests_valid",
bundle = ":badmanifests",
tags = ["manual"],
)
sh_test(
name = "validate_rejects_bad_test",
srcs = ["validate_rejects_bad.sh"],
data = [":badmanifests_valid"],
env = {"VALIDATOR": "$(rootpath :badmanifests_valid)"},
)
examples/smoke/cmd/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library")
load("@rules_python//python:defs.bzl", "py_test")
load("//oci:defs.bzl", "k8s_operator_image")
# gazelle:ignore
go_library(
name = "cmd_lib",
srcs = ["main.go"],
importpath = "github.com/tomato-bazel/rules_k8s/examples/smoke/cmd",
)
# A PLAIN go_binary. Note what is absent: no goos/goarch, no `pure = "on"`, no
# second cross-compiled target. The fleet's operators each declare a separate
# `manager_linux_amd64` go_binary beside the real one purely to feed the image;
# the transition makes that unnecessary.
go_binary(
name = "manager",
embed = [":cmd_lib"],
)
# THE SPIKE. This must build a linux/amd64 image on a macOS host with NO
# --platforms flag and NO --config, and `:operator-image_push` must ANALYZE —
# which real repos cannot do today, hence their crane bypass.
k8s_operator_image(
name = "operator-image",
binary = ":manager",
repository = "example.com/smoke/operator",
visibility = ["//visibility:public"],
)
# The regression guard. A transition that silently stops firing still builds and
# still pushes — it just ships a host-arch binary that cannot exec in the cluster.
# Nothing else in the build would catch that.
py_test(
name = "image_is_linux_amd64_test",
srcs = ["assert_image.py"],
main = "assert_image.py",
# The transitioned IMAGE, not the layer target: a direct dep on the layer
# resolves in the default configuration and hands back a host-arch binary.
data = [":operator-image"],
env = {"LAYOUT": "$(rootpath :operator-image)"},
)
examples/smoke/api/v1/BUILD.bazel
load("@rules_go//go:def.bzl", "go_library")
# gazelle:ignore
go_library(
name = "v1",
srcs = ["widget_types.go"],
importpath = "github.com/tomato-bazel/rules_k8s/examples/smoke/api/v1",
visibility = ["//visibility:public"],
deps = [
"@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
"@io_k8s_apimachinery//pkg/runtime",
"@io_k8s_apimachinery//pkg/runtime/schema",
],
)Rules & providers#
Generated with Stardoc from the module's .bzl sources.
from docs/crd_providers.md
Providers for the CRD half of rules_k8s.
K8sCrdInfo
load("@rules_k8s//crd:providers.bzl", "K8sCrdInfo")
K8sCrdInfo(crds, group)
CRD schemas generated from kubebuilder markers in a Go API package.
Carries the schemas as a directory rather than a file list because the set of
Kinds is not knowable at analysis time — controller-tools discovers it by
type-checking the package, inside the action. Consumers that need per-Kind facts
read the listing output group, which the action writes.
FIELDS
K8sTransitiveSchemasInfo
load("@rules_k8s//crd:providers.bzl", "K8sTransitiveSchemasInfo")
K8sTransitiveSchemasInfo(schemas)
Transitive set of CRD schemas, threaded by the _k8s_schemas aspect.
Exists so k8s_validate can resolve what to validate a manifest against instead
of making every caller hand-list the CRDs — including reaching through
intermediate targets that don’t themselves speak k8s (a filegroup, a genrule),
which is the part plain provider-threading can’t do.
FIELDS
| Name | Description |
|---|---|
| schemas | depset[K8sCrdInfo]: every CRD set reachable through deps. |
from docs/crd_toolchains.md
The CRD codegen toolchain: controller-tools plus the driver that feeds it.
k8s_crd_toolchain
load("@rules_k8s//crd:toolchains.bzl", "k8s_crd_toolchain")
k8s_crd_toolchain(name, driver, gen)
Declares a CRD codegen toolchain.
The default implementation (@rules_k8s//crd:default_k8s_crd_toolchain) builds
controller-tools from the version pinned in rules_k8s’s go.mod. That pin is a
schema decision — see the comment there — so an override should be a deliberate
“we generate with a different controller-tools”, not a convenience.
Register with --extra_toolchains in .bazelrc, never register_toolchains() in
MODULE.bazel: the latter propagates to every consumer, which would drag a Go SDK
into the Rust services that only ever read CRD schemas.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| driver | The go/packages external driver. Must read its package-graph file list from K8S_CRD_DRIVER_PKG_JSON. | Label | required | |
| gen | The CRD generator binary. Must accept -out DIR [-expect-group G] [-listing F] PATTERN.... | Label | required |
K8sCrdToolchainInfo
load("@rules_k8s//crd:toolchains.bzl", "K8sCrdToolchainInfo")
K8sCrdToolchainInfo(gen, driver)
The tools that turn kubebuilder markers into CRD YAML inside an action.
FIELDS
from docs/k8s_defs.md
Public API for the manifest half of rules_k8s.
load(“@rules_k8s//k8s:defs.bzl”, “k8s_bundle”, “k8s_validate”)
k8s_bundle
load("@rules_k8s//k8s:defs.bzl", "k8s_bundle")
k8s_bundle(name, deps, srcs)
Collect Kubernetes manifests into one conflict-checked directory.
Fails the build if two manifests declare the same group/Kind/namespace/name.
That is the whole point over a filegroup: applying such a pair keeps whichever
came last, and which that is depends on ordering. Note identity excludes the API
VERSION — Foo/v1 and Foo/v1beta1 with the same name are the same object.
Manifests are placed, never rewritten: source bytes are copied verbatim, so key order and comments survive. A bundle that round-tripped YAML through a marshaller would silently rewrite the API surface it is supposed to be checking.
deps composes bundles without needing an aspect; the _k8s_schemas aspect is
there to reach CRD schemas through targets that don’t speak k8s.
k8s_bundle(name = "apps", srcs = glob(["argocd/apps/*.yaml"]))
k8s_bundle(name = "fleet", deps = [":apps", "//op-a:bundle"])
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Other bundles, k8s_object targets, k8s_crd_library targets, or plain file-providing targets. | List of labels | optional | [] |
| srcs | Manifest files to include directly. | List of labels | optional | [] |
k8s_diff
load("@rules_k8s//k8s:defs.bzl", "k8s_diff")
k8s_diff(name, bundle)
bazel run to diff a bundle against the live cluster. Read-only.
bazel run //argocd:apps_diff -- --context=my-cluster
Extra arguments after -- pass through to kubectl diff. Exits non-zero when
there is a difference, which is kubectl’s own convention.
Uses whatever kubectl is on your PATH — see //kubectl/toolchains.bzl for why pinning one would be wrong.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| bundle | The k8s_bundle to compare against the cluster. | Label | required |
k8s_object
load("@rules_k8s//k8s:defs.bzl", "k8s_object")
k8s_object(name, srcs, expect_api_version, expect_kind)
Adopt checked-in Kubernetes manifests into the build graph.
No codegen and no rewriting — it types the graph so k8s_bundle and
k8s_validate can consume the files, and optionally asserts that a manifest is
what the BUILD file says it is.
Reach for k8s_bundle(srcs = ...) directly unless you want the assertion; this
rule earns its place when a manifest’s identity is load-bearing elsewhere.
k8s_object(
name = "myresource",
srcs = ["myresource.yaml"],
expect_kind = "MyResource",
expect_api_version = "platform.example.com/v1",
)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| srcs | Manifest files. Each may hold multiple documents. | List of labels | required | |
| expect_api_version | If set, every object in srcs must be this apiVersion, or the build fails. | String | optional | "" |
| expect_kind | If set, every object in srcs must be this Kind, or the build fails. | String | optional | "" |
K8sBundleInfo
load("@rules_k8s//k8s:defs.bzl", "K8sBundleInfo")
K8sBundleInfo(dir, files)
A collected, conflict-checked set of Kubernetes objects.
FIELDS
| Name | Description |
|---|---|
| dir | File: TreeArtifact of the manifests, one file per object, named from its identity. |
| files | depset[File]: the source manifests that composed it. |
K8sObjectInfo
load("@rules_k8s//k8s:defs.bzl", "K8sObjectInfo")
K8sObjectInfo(files)
Kubernetes manifests contributed by a target.
Carries files and nothing else. Identity (group/version/kind, name, namespace) is resolved by the bundle ACTION, not here — Starlark cannot read files, so a rule that adopts a checked-in manifest has no way to know its Kind at analysis time. Duplicate detection and the listing therefore live in the tool. (rules_cloudformation’s stack aggregator makes the same trade for the same reason: it can’t load many hundreds of per-kind providers, so it reads the shards.)
FIELDS
| Name | Description |
|---|---|
| files | depset[File]: manifest YAMLs. Each may hold multiple documents. |
k8s_validate
load("@rules_k8s//k8s:defs.bzl", "k8s_validate")
k8s_validate(name, **kwargs)
Validate a k8s_bundle against its CRD schemas. See _k8s_validate_test.
A macro only because Bazel requires test rule classes to end in _test.
PARAMETERS
| Name | Description | Default Value |
|---|---|---|
| name | - | none |
| kwargs | - | none |
k8s_schemas_aspect
load("@rules_k8s//k8s:defs.bzl", "k8s_schemas_aspect")
k8s_schemas_aspect()
Collect K8sCrdInfo from a target and everything reachable via deps/srcs.
Exists so k8s_validate resolves its own schemas instead of making callers
hand-list every CRD set a bundle might contain:
k8s_bundle(name = "fleet", deps = ["//op-a:bundle", "//op-b:bundle"])
k8s_validate(name = "fleet_valid", bundle = ":fleet") # finds both operators' CRDs
Plain provider-threading would cover that much. What needs an aspect is reaching
THROUGH a target that doesn’t speak k8s at all — a filegroup or genrule
wrapping several k8s_crd_library targets. Those return neither of our providers,
so nothing would propagate; an aspect visits them anyway.
srcs is walked as well as deps because a filegroup carries its contents there.
ASPECT ATTRIBUTES
| Name | Type |
|---|---|
| deps | String |
| srcs | String |
ATTRIBUTES
Conformance#
No gate findings. 17 contested atoms. See how gating works or the full report.
Contested atoms
Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.
| Atom | Resolved here | Elsewhere |
|---|---|---|
apple_support | 1.24.2 | 2.2.0 ×1 |
aspect_bazel_lib | 2.22.5 | 2.8.1 ×1 |
bazel_lib | 3.0.0 | 3.2.2 ×17 |
bazel_skylib | 1.8.2 | 1.9.0 ×2 |
gawk | 5.3.2.bcr.1 | 5.3.2.bcr.3 ×17 |
gazelle | 0.51.0 | 0.30.0 ×50.36.0 ×140.44.0 ×1 |
jq.bzl | 0.1.0 | 0.4.0 ×17 |
nlohmann_json | 3.6.1 | 3.12.0.bcr.1 ×1 |
package_metadata | 0.0.5 | 0.0.2 ×27 |
protobuf | 33.4 | 34.0.bcr.1 ×2 |
rules_go | 0.60.0 | 0.39.1 ×5 |
rules_jvm_external | 6.7 | 6.8 ×4 |
rules_python | 1.7.0 | 2.0.1 ×1 |
rules_swift | 3.1.2 | 3.6.1 ×1 |
tar.bzl | 0.5.1 | 0.10.4 ×170.6.0 ×1 |
upb | 0.0.0-20220923-a547704 | 0.0.0-20230516-61a97ef ×1 |
yq.bzl | 0.1.1 | 0.3.4 ×17 |
Dependencies#
Depends on
Used by (1 in the registry)
Versions#
1 published version, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (sha256) | Source archive |
|---|---|---|
0.0.3 latest | DiCtdaesjvmTcgGe… | tag archive ↗ |
Changelog#
v0.0.3
First usable release. 0.0.1 and 0.0.2 are withdrawn — their tags are deleted and they are not in the registry.
k8s_crd_library— controller-tools as a real Bazel action: sandboxed, cached, remotable, with no host Go, nogo list, no module cache, no network and nobazel query. Verified byte-identical to stockcontroller-gen v0.16.5.k8s_object/k8s_bundle/k8s_validate/k8s_diff— adopt checked-in manifests, collect them with conflict detection, check them against CRD schemas generated in the same build, and diff a bundle against a live cluster (read-only).k8s_operator_image— a linux/amd64 operator image on any host via a per-target platform transition, sooci_pushworks without a global--platformsretargeting the push tooling out of a cpp toolchain.
Fixed before release, each of which built green and failed only in production:
k8s_bundlesilently dropped manifests whose separator carried an inline document (--- {kind: Secret, ...}) or a trailing tab. Now uses apimachinery’sYAMLReader— the splitter kubectl applies with. A bundle that disagrees with the applier’s splitter is wrong by definition.k8s_operator_image’s entrypoint named a file that wasn’t there for analiasor anout =binary: it came from the label name while the file was packaged under its basename. Green build, green push, CrashLoopBackOff.- The CRD driver returned a vacuous success when a root’s source could not be read, emitting nothing — or a CRD under the wrong version, since controller-tools derives the version from the package name.
k8s_validate(strict = True)was a no-op, then over-corrected into rejecting valid manifests (composition branches, embedded resources).