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
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@ build:

test:
go test ./... -v

# Regenerate the CUE definitions kir matches against from the Kubernetes Go
# types. Run after bumping k8s.io/api; the output is checked in.
#
# GOTOOLCHAIN is pinned because `go run pkg@version` builds the cue command with
# the Go version in *cue's* go.mod (1.25), while k8s.io/api now requires 1.26 —
# without it the generator fails with "package requires newer Go version".
schema:
cd k8s && GOTOOLCHAIN=go1.26.5 go run cuelang.org/go/cmd/cue@v0.17.1 get go k8s.io/api/core/v1
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,37 @@ $ go run main.go approvals/kir_test.TestKind.Job.input.yaml | xargs docker scout

## How `kir` treats each document

A manifest stream usually mixes workloads with other objects. `kir` handles each by kind:
A manifest stream usually mixes workloads with other objects. `kir` handles each by what it contains, not by its kind:

| Document | Result |
| --- | --- |
| A workload — `Pod`, `Deployment`, `DaemonSet`, `ReplicaSet`, `StatefulSet`, `Job`, `CronJob` | its images are printed to stdout |
| A valid object with no images — `Service`, `ConfigMap`, `Secret`, … | skipped silently (exit 0) |
| Anything containing a `PodSpec` — `Pod`, `Deployment`, , `CronJob`, and custom resources like an Argo `Rollout` | its images are printed to stdout |
| A valid object with no `PodSpec` — `Service`, `ConfigMap`, `Secret`, … | skipped silently (exit 0) |
| Malformed or unreadable input | reported on stderr, non-zero exit |
| A workload whose image value isn't a valid image reference | that image is reported on stderr with a non-zero exit; the document's other images are still printed |
| An unrecognized custom resource (CRD) | skipped for now — see [#75](https://github.com/MPV/kir/issues/75) |

There is no list of supported kinds. A document yields images if it holds something the schema recognises as a `PodSpec`, so a custom resource that embeds one works without `kir` knowing anything about it.

So stdout carries only images and stderr stays quiet for normal input. See [ADR 0007](docs/adr/0007-document-classification.md) for the rationale.

### Teaching `kir` a different shape

What counts as a `PodSpec` is a [CUE](https://cuelang.org) schema, not Go code — generated from the Kubernetes API types by `make schema`. Point `--schema` at your own to recognise containers the Kubernetes API does not describe. Your schema is loaded alongside the generated definitions, so it can extend the official type instead of restating it:

```cue
package podspec

import corev1 "k8s.io/api/core/v1"

#Container: {
corev1.#Container
sidecarPolicy?: string
}

#Containers: [...#Container]
#EphemeralContainers: [...corev1.#EphemeralContainer]
```

```shell
$ kir --schema my-containers.cue manifests/
```
2 changes: 1 addition & 1 deletion approvals/kir_test.TestCLI.Usage.stderr.approved.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Usage: kir <file_path> [<file_path_2> ...] | kir - | kir --version
Usage: kir [--schema <file.cue>] <file_path> [<file_path_2> ...] | kir - | kir --version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
18 changes: 18 additions & 0 deletions approvals/kir_test.TestCustomResource.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: my-rollout
spec:
replicas: 3
strategy:
canary:
steps:
- setWeight: 20
template:
spec:
containers:
- name: app
image: my-registry/app:1.4.2
initContainers:
- name: setup
image: busybox:1.36
Empty file.
2 changes: 2 additions & 0 deletions approvals/kir_test.TestCustomResource.stdout.approved.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
my-registry/app:1.4.2
busybox:1.36
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
checkout:2.0.0
9 changes: 9 additions & 0 deletions approvals/kir_test.TestCustomSchema.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: platform.example.com/v1
kind: ManagedService
metadata:
name: checkout
spec:
containers:
- name: app
image: checkout:2.0.0
sidecarPolicy: Always
18 changes: 18 additions & 0 deletions approvals/kir_test.TestCustomSchema.schema.cue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// A replacement for the embedded schema, teaching kir about containers that
// carry a field the Kubernetes API does not define.
//
// It extends the official type rather than restating it: corev1.#Container is
// embedded, and the extra field is declared alongside it. Passed with --schema,
// so no rebuild is needed.
package podspec

import corev1 "k8s.io/api/core/v1"

#Container: {
corev1.#Container
sidecarPolicy?: string
}

#Containers: [...#Container]

#EphemeralContainers: [...corev1.#EphemeralContainer]
2 changes: 1 addition & 1 deletion approvals/kir_test.TestFailure.BadYAML.stderr.approved.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
error: yaml: line 9: did not find expected ',' or ']'
error: error converting YAML to JSON: yaml: line 9: did not find expected ',' or ']'
Original file line number Diff line number Diff line change
@@ -1 +1 @@
error: yaml: line 9: did not find expected ',' or ']'
error: error converting YAML to JSON: yaml: line 9: did not find expected ',' or ']'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
9 changes: 9 additions & 0 deletions approvals/kir_test.TestKind.PodTemplate.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v1
kind: PodTemplate
metadata:
name: tmpl
template:
spec:
containers:
- name: worker
image: worker:3.1
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
worker:3.1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
11 changes: 11 additions & 0 deletions approvals/kir_test.TestKind.ReplicationController.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ReplicationController
metadata:
name: legacy
spec:
replicas: 2
template:
spec:
containers:
- name: web
image: nginx:1.27
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nginx:1.27
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
9 changes: 9 additions & 0 deletions approvals/kir_test.TestSkipsNonWorkloads.Lookalike.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: logistics.example.com/v1
kind: ShippingManifest
metadata:
name: not-a-pod
spec:
containers:
- name: cargo-hold-1
capacity: 40ft
image: photo-of-container.jpg
Empty file.
Empty file.
40 changes: 35 additions & 5 deletions approvals/kir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ func newlineTerminated(s string) string {
}

func TestKind(t *testing.T) {
kinds := []string{"Pod", "CronJob", "DaemonSet", "Deployment", "Job", "ReplicaSet", "StatefulSet"}
// PodTemplate and ReplicationController are built-in kinds carrying a
// PodSpec that the previous fixed kind list omitted.
kinds := []string{"Pod", "CronJob", "DaemonSet", "Deployment", "Job", "PodTemplate", "ReplicaSet", "ReplicationController", "StatefulSet"}

for _, kind := range kinds {
t.Run(kind, func(t *testing.T) {
Expand All @@ -51,11 +53,23 @@ func TestKind(t *testing.T) {
}
}

// A non-workload kind (Service) is skipped: no images, no error, exit 0.
// A non-workload document is skipped: no images, no error, exit 0. Service is
// a built-in without a PodSpec; Lookalike is a custom resource with a field
// named containers holding something that is not a container, which pins the
// cost of matching on shape — a name alone must not be enough to match.
func TestSkipsNonWorkloads(t *testing.T) {
t.Run("Service", func(t *testing.T) {
verify(t, []string{"kir_test.TestSkipsNonWorkloads.Service.input.yaml"}, nil)
})
for _, name := range []string{"Service", "Lookalike"} {
t.Run(name, func(t *testing.T) {
verify(t, []string{"kir_test.TestSkipsNonWorkloads." + name + ".input.yaml"}, nil)
})
}
}

// The reach structural discovery buys: a custom resource the Kubernetes scheme
// cannot decode, whose embedded PodSpec is found anyway. Nothing in kir names
// the Rollout kind.
func TestCustomResource(t *testing.T) {
verify(t, []string{"kir_test.TestCustomResource.input.yaml"}, nil)
}

func TestMultiple(t *testing.T) {
Expand Down Expand Up @@ -133,3 +147,19 @@ func TestCLI(t *testing.T) {
verify(t, []string{"--version"}, nil)
})
}

// The schema is data, so what counts as a PodSpec can be replaced without
// rebuilding kir. The fixture's containers carry a field the Kubernetes API
// does not define: the embedded schema rejects them, and a schema passed with
// --schema accepts them.
func TestCustomSchema(t *testing.T) {
input := "kir_test.TestCustomSchema.input.yaml"

t.Run("Default", func(t *testing.T) {
verify(t, []string{input}, nil)
})

t.Run("Extended", func(t *testing.T) {
verify(t, []string{"--schema", "kir_test.TestCustomSchema.schema.cue", input}, nil)
})
}
37 changes: 34 additions & 3 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"fmt"
"io"
"log"
"os"
"strings"

"github.com/mpv/kir/fileutil"
"github.com/mpv/kir/imageref"
"github.com/mpv/kir/k8s"
"github.com/mpv/kir/processor"
)

Expand All @@ -34,7 +36,13 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
logger := log.New(stderr, "", 0)

if len(args) == 0 {
logger.Print("Usage: kir <file_path> [<file_path_2> ...] | kir - | kir --version")
logger.Print("Usage: kir [--schema <file.cue>] <file_path> [<file_path_2> ...] | kir - | kir --version")
return 1
}

args, matcher, err := schemaFlag(args)
if err != nil {
logger.Printf("error: %v", err)
return 1
}

Expand All @@ -44,7 +52,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if stdin == nil {
stdin = strings.NewReader("")
}
images, err := processor.ProcessStdin(stdin)
images, err := processor.ProcessStdin(matcher, stdin)
failures := logErrors(logger, err)
failures += printImages(stdout, logger, "stdin", images)
if failures > 0 {
Expand All @@ -64,7 +72,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
failures := 0
for _, filePath := range files {
images, err := processor.ProcessFile(filePath)
images, err := processor.ProcessFile(matcher, filePath)
// Not `continue`: a file that failed on one document may still have
// yielded images from the others, and dropping them would defeat the
// point of reporting the failure.
Expand All @@ -77,6 +85,29 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}

// schemaFlag consumes a leading `--schema <file.cue>` and returns the remaining
// arguments alongside the matcher to use. Without the flag the embedded schema
// applies, so what kir recognises as a PodSpec can be replaced — to teach it a
// resource whose containers carry extra fields, say — without rebuilding it.
func schemaFlag(args []string) ([]string, *k8s.Matcher, error) {
if len(args) == 0 || args[0] != "--schema" {
return args, k8s.DefaultMatcher(), nil
}
if len(args) < 2 {
return nil, nil, fmt.Errorf("--schema requires a file")
}

schema, err := os.ReadFile(args[1])
if err != nil {
return nil, nil, fmt.Errorf("error reading schema: %v", err)
}
matcher, err := k8s.NewMatcher(string(schema))
if err != nil {
return nil, nil, err
}
return args[2:], matcher, nil
}

// logErrors writes one "error:" line per failure and returns how many it wrote.
//
// A single input can fail on more than one document, and ProcessReader packs
Expand Down
77 changes: 77 additions & 0 deletions docs/adr/0009-podspec-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 9. Find PodSpecs with a CUE schema generated from the Kubernetes types

- Status: **proposed** — one of four candidate answers to #26, supersedes [0001](0001-typed-kubernetes-decoding.md) if accepted
- Date: 2026-08-09

## Context

[ADR 0001](0001-typed-kubernetes-decoding.md) decodes each document with the
typed client-go scheme and reads the PodSpec through a type switch over seven
hardcoded kinds. #26 asks for the PodSpec to be *found* rather than looked up,
and suggests CUE as the way to describe what is being looked for.

## Decision

Decode each document into plain Go values, walk it, and ask a CUE schema whether
each node is PodSpec-shaped.

The schema is **generated from `k8s.io/api/core/v1` by `cue get go`** (`make
schema`, output checked in under `k8s/cue.mod/gen/`) rather than written by
hand. What kir maintains is nineteen lines that import the generated package:

```cue
import corev1 "k8s.io/api/core/v1"

#Containers: [...corev1.#Container]
#EphemeralContainers: [...corev1.#EphemeralContainer]
```

Generated definitions are CUE definitions and so are closed: a container
carrying a field the Kubernetes API does not define is a mismatch. That is what
stops a resource with a `containers` field holding something else from being
read as a workload.

Because the schema is data, it can be replaced at runtime — `kir --schema
my.cue` — and a user schema is loaded *inside the same module*, so it can import
the official definitions and extend them rather than restate them.

## Consequences

Custom resources work, as do the built-ins the old kind list omitted, and `List`
stops being a special case. Those gains are shared with options B and D. What is
unique here is the **replaceable schema**, covered by `TestCustomSchema`.

Reusing the generated types removes the fidelity objection to a hand-written
CUE schema — there is nothing to drift. It replaces it with three sharper ones:

- **Cost.** 105 ms → **2163 ms** over 1000 documents, ~21× today and ~16× option
B, which validates the same shapes using the same Kubernetes types as Go code.
Unifying against the full closed `#Container` and its dependency graph is
simply expensive.
- **A trap.** Pre-evaluating the definitions (`cue.Value.Eval()`) makes it ~7×
faster and **silently discards closedness**, so every lookalike starts
matching. Only the `TestSkipsNonWorkloads.Lookalike` fixture catches it. An
optimisation that turns a correctness guarantee off without erroring is a
sharp edge to hand to a future maintainer.
- **An impedance mismatch.** Manifests decode YAML-to-JSON-to-Go, making every
number a `float64`; offering that to a schema saying `int32` rejects every
container declaring a port. Candidates therefore reach CUE as JSON rather than
via `ctx.Encode`. `TestFindImagesIntegerFields` pins it.
- **Code generation that needs maintaining.** 47 generated `.cue` files checked
in and a `make schema` step to re-run whenever `k8s.io/api` is bumped. The
v0.32.3 → v0.36.3 bump showed what that costs in practice, twice over:
`go mod tidy` drops `k8s.io/api` from `go.mod` — kir's Go code does not import
it, and CUE imports are invisible to the Go toolchain — so the schema's own
source has to be pinned by a build-tagged blank import (`k8s/schema_source.go`)
or it silently disappears; and `go run cuelang.org/go/cmd/cue@v0.17.1` builds
the generator with the Go version in *CUE's* `go.mod` (1.25) while
`k8s.io/api` v0.36.3 requires 1.26, so generation fails with "package requires
newer Go version" until `GOTOOLCHAIN` is pinned in the target. Neither is
hard once known; both are invisible until a bump breaks them.
- The Go floor is no longer a cost: CUE needs 1.25 and `master` is on 1.26.5, so
this no longer moves `.tool-versions`.

The comparison this sets up: option B gets the same reach and the same lookalike
rejection from `k8s.io/api` — a dependency kir already has — at 1/12th the
runtime, a third of the binary, and no code generation. CUE earns its keep here
only if a user-replaceable schema is worth that.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ date the decision was actually made.
| [0006](0006-conventional-commits-and-releases.md) | Automate releases from Conventional Commits | 2026-08-06 |
| [0007](0007-document-classification.md) | How kir classifies each document (workload / image-less / unprocessable) | 2026-08-08 |
| [0008](0008-best-effort-processing-and-exit-codes.md) | Best-effort processing; failures surface via the exit code | 2026-08-09 |
| [0009](0009-podspec-discovery.md) | Find PodSpecs with a CUE schema generated from the Kubernetes types (proposed — #26) | 2026-08-09 |
Loading
Loading