tomato·bazeldocs v0 · latest
Docs/Reference/Modules/rules_k8s

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.

Latest0.0.3
Versions1
CategoryBazel rules
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/rules_k8s/
Sourcegithub.com/tomato-bazel/rules_k8s
MODULE.bazelstarlark
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

RuleKindWhat it does
k8s_crd_libraryrulego_library deps → CRD YAML as a declared TreeArtifact. Emits K8sCrdInfo. Optional post_processors.
k8s_objectruleAdopt checked-in manifests into the graph; optionally assert their Kind/apiVersion.
k8s_bundleruleCollect manifests into one directory, failing on duplicate group/Kind/namespace/name.
k8s_validatetestkubeconform a bundle against CRD schemas resolved through its deps.
k8s_diffexecutablebazel run — diff a bundle against a live cluster. Read-only.
k8s_operator_imagemacroAn operator binary → a linux/amd64 image, via a per-target transition. Emits <name>, <name>_push, <name>_tarball.
k8s_crd_toolchainruleDeclares 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 Application pinned 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 default is a raw GitHub URL, which would make every validation a network fetch and fail on RBE.

Testing

TargetWhat it proves
//examples/smoke:crds_match_stock_controller_genThe generated CRD is byte-identical to stock controller-gen v0.16.5.
//examples/smoke:crds_listing_testNo Kind silently appeared or vanished.
//crd/private/driver:driver_testThe 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.bzl loads @rules_go//go/tools/gopackagesdriver:aspect.bzl, which rules_go neither wraps nor exports (Visibility error). Vendoring the aspect doesn’t help either: it reads GoStdLib, which @rules_go//go:def.bzl doesn’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.bzl loads @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:defs does ship a bzl_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/driver vendors files from rules_go’s go/tools/gopackagesdriver, and the rule loads go_pkg_info_aspect from 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

NameDescription
crdsFile: a TreeArtifact directory of CRD manifests, one <group>_<plural>.yaml per Kind.
groupstr: the API group these CRDs are in, e.g. ‘platform.example.com’. Declared on the rule and verified against the generated output, so it cannot rot.

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

NameDescription
schemasdepset[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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
driverThe go/packages external driver. Must read its package-graph file list from K8S_CRD_DRIVER_PKG_JSON.Labelrequired
genThe CRD generator binary. Must accept -out DIR [-expect-group G] [-listing F] PATTERN....Labelrequired

K8sCrdToolchainInfo

load("@rules_k8s//crd:toolchains.bzl", "K8sCrdToolchainInfo")

K8sCrdToolchainInfo(gen, driver)

The tools that turn kubebuilder markers into CRD YAML inside an action.

FIELDS

NameDescription
genFilesToRunProvider: the CRD generator — controller-tools’ genall driven as a library, writing to a named directory.
driverFilesToRunProvider: the go/packages external driver. gen reaches it via GOPACKAGESDRIVER; it is never invoked directly.

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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
depsOther bundles, k8s_object targets, k8s_crd_library targets, or plain file-providing targets.List of labelsoptional[]
srcsManifest files to include directly.List of labelsoptional[]

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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
bundleThe k8s_bundle to compare against the cluster.Labelrequired

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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
srcsManifest files. Each may hold multiple documents.List of labelsrequired
expect_api_versionIf set, every object in srcs must be this apiVersion, or the build fails.Stringoptional""
expect_kindIf set, every object in srcs must be this Kind, or the build fails.Stringoptional""

K8sBundleInfo

load("@rules_k8s//k8s:defs.bzl", "K8sBundleInfo")

K8sBundleInfo(dir, files)

A collected, conflict-checked set of Kubernetes objects.

FIELDS

NameDescription
dirFile: TreeArtifact of the manifests, one file per object, named from its identity.
filesdepset[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

NameDescription
filesdepset[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

NameDescriptionDefault 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

NameType
depsString
srcsString

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.

AtomResolved hereElsewhere
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#

rules_k8s in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

bazel_skylib1.8.2platforms1.0.0aspect_bazel_lib2.22.5rules_go0.60.0gazelle0.51.0rules_github0.1.2rules_oci2.2.6rules_pkg1.0.1rules_shell0.6.1rules_python1.7.0stardoc0.7.2dev

Used by (1 in the registry)

Versions#

1 published version, newest first. Each resolves to an immutable, integrity-checked archive.

VersionIntegrity (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, no go list, no module cache, no network and no bazel query. Verified byte-identical to stock controller-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, so oci_push works without a global --platforms retargeting the push tooling out of a cpp toolchain.

Fixed before release, each of which built green and failed only in production:

  • k8s_bundle silently dropped manifests whose separator carried an inline document (--- {kind: Secret, ...}) or a trailing tab. Now uses apimachinery’s YAMLReader — 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 an alias or an out = 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).

← All modules