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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,35 @@ A manifest stream usually mixes workloads with other objects. `kir` handles each

| Document | Result |
| --- | --- |
| A workload — `Pod`, `Deployment`, `DaemonSet`, `ReplicaSet`, `StatefulSet`, `Job`, `CronJob` | its images are printed to stdout |
| A configured kind — `Pod`, `Deployment`, `DaemonSet`, `ReplicaSet`, `ReplicationController`, `PodTemplate`, `StatefulSet`, `Job`, `CronJob`, `List` | its images are printed to stdout |
| A valid object with no images — `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) |
| A custom resource kir has not been told about | skipped silently (exit 0) — describe it with `--config` |

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 custom resource

Which kinds hold images, and where, is configuration rather than Go code — see [`k8s/resources.yaml`](k8s/resources.yaml). Locations are [JMESPath](https://jmespath.org) expressions. Point `--config` at your own file to describe a custom resource; entries are merged over the built-in ones, keyed by kind:

```yaml
# rollouts.yaml
resources:
- kind: Rollout
podSpecs: [spec.template.spec]
```

```shell
$ kir --config rollouts.yaml manifests/
```

Use `containers` for a resource that holds bare containers instead of a `PodSpec`. An Argo `Workflow` keeps a list of templates, each holding a container, a script, or neither — one expression covers all of it:

```yaml
resources:
- kind: Workflow
containers: ["spec.templates[*].[container, script][]"]
```

Expressions are compiled when the file loads, so a typo is reported then rather than silently matching nothing.
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 [--config <file.yaml>] <file_path> [<file_path_2> ...] | kir - | kir --version
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,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.
7 changes: 7 additions & 0 deletions approvals/kir_test.TestCustomResource.Workflow.config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# An Argo Workflow keeps images in a list of templates, and each template holds
# either a container, a script, or neither (a dag, a suspend). One expression
# covers it: select both shapes from every template, flatten, and the templates
# that have neither drop out.
resources:
- kind: Workflow
containers: ["spec.templates[*].[container, script][]"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
23 changes: 23 additions & 0 deletions approvals/kir_test.TestCustomResource.Workflow.input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
name: pipeline
spec:
entrypoint: main
templates:
- name: main
dag:
tasks:
- name: build
template: build
- name: build
container:
image: builder:1.2.0
command: [make]
- name: report
script:
image: python:3.12
source: |
print("done")
- name: approve
suspend: {}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
builder:1.2.0
python:3.12
5 changes: 5 additions & 0 deletions approvals/kir_test.TestCustomResource.config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Teaches kir where an Argo Rollout keeps its PodSpec. Merged over the built-in
# configuration by --config, so no rebuild is needed.
resources:
- kind: Rollout
podSpecs: [spec.template.spec]
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
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.
42 changes: 38 additions & 4 deletions approvals/kir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ 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; they are entries in
// the built-in configuration now.
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,10 +54,41 @@ func TestKind(t *testing.T) {
}
}

// A non-workload kind (Service) is skipped: no images, no error, exit 0.
// A document kir has no configuration for is skipped: no images, no error,
// exit 0. Service is a built-in without a PodSpec; Lookalike is an undescribed
// custom resource, which is skipped for the same reason — being undescribed —
// whether or not it happens to have a field named containers.
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)
})
}
}

// A custom resource is invisible until described, and read like a built-in once
// it is. Both halves are pinned, because the first is the cost of this approach
// and the second is the benefit.
func TestCustomResource(t *testing.T) {
input := "kir_test.TestCustomResource.input.yaml"

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

t.Run("Configured", func(t *testing.T) {
verify(t, []string{"--config", "kir_test.TestCustomResource.config.yaml", input}, nil)
})

// An Argo Workflow holds bare containers in a list of templates, each of
// which has a container, a script, or neither. One JMESPath expression
// covers it — select both shapes across the list, flatten, and templates
// with neither drop out — which a plain field path could not express.
t.Run("Workflow", func(t *testing.T) {
verify(t, []string{
"--config", "kir_test.TestCustomResource.Workflow.config.yaml",
"kir_test.TestCustomResource.Workflow.input.yaml",
}, nil)
})
}

Expand Down
38 changes: 35 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 [--config <file.yaml>] <file_path> [<file_path_2> ...] | kir - | kir --version")
return 1
}

args, config, err := configFlag(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(config, 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(config, 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,30 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}

// configFlag consumes a leading `--config <file.yaml>` and returns the
// remaining arguments alongside the configuration to use. The file is merged
// over the built-in one, so a resource can be described — or a built-in
// corrected — without rebuilding kir.
func configFlag(args []string) ([]string, *k8s.Config, error) {
config := k8s.DefaultConfig()
if len(args) == 0 || args[0] != "--config" {
return args, config, nil
}
if len(args) < 2 {
return nil, nil, fmt.Errorf("--config requires a file")
}

data, err := os.ReadFile(args[1])
if err != nil {
return nil, nil, fmt.Errorf("error reading config: %v", err)
}
extra, err := k8s.LoadConfig(data)
if err != nil {
return nil, nil, err
}
return args[2:], config.Merge(extra), 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
92 changes: 92 additions & 0 deletions docs/adr/0009-podspec-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 9. Find images at configured per-kind JMESPath expressions

- 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,
mainly so that custom resources embedding one can work.

The kinds and their paths are not wrong — they are simply *in Go*, which is why
a new one needs a release.

## Decision

Keep the lookup, and move it out of Go into configuration.

`k8s/resources.yaml`, embedded, lists each kind and where it holds its images,
as **JMESPath** expressions (`spec`, `spec.template.spec`,
`spec.jobTemplate.spec.template.spec`). A `documents` expression names nodes to
process as objects in their own right, which is how a `List` unwraps its items —
the special case becomes two lines of configuration. A `containers` expression
selects containers directly, for resources holding bare containers rather than a
PodSpec.

`kir --config my.yaml` merges a user's file over the built-in one. Entries are
keyed by kind, so a custom resource can be added and a built-in corrected.

Documents are decoded into plain Go values, since a custom resource has to be
readable without the scheme.

## Consequences

This is the cheapest option by every mechanical measure, because the lookup
never has to *decide* anything: 105 ms → 76 ms over 1000 documents (faster than
today, having dropped typed decoding), a 4.1 MB binary against today's 27.2 MB,
and a dependency list that goes from 74 `go.sum` lines to 42 — both
`k8s.io/client-go` and `k8s.io/api` fall away, leaving `sigs.k8s.io/yaml`,
`k8s.io/apimachinery` for the YAML reader, and `go-jmespath`.

### Why JMESPath rather than field paths

An earlier revision resolved dot-separated paths with ~25 lines of Go. That
covers every built-in kind, and for navigation the two are indistinguishable —
`spec.template.spec` is the same string either way, and the existing
configuration needed no edits when the resolver was swapped.

What it could not express is **selection**, and real custom resources need it.
An Argo `Workflow` holds a list of templates, each with a container, a script,
or neither (a dag, a suspend); one expression covers all of it:

```
spec.templates[*].[container, script][]
```

Multi-select and flattening are not field navigation, and the projection
correctly drops templates holding neither. `TestCustomResource/Workflow` pins
it end to end.

Two smaller gains come along: expressions are compiled when the config loads, so
a typo is an error naming the kind and field rather than a path that silently
matches nothing all run (`TestLoadConfigRejectsBadExpression`), and JMESPath is
a specified language users may already know from Kyverno or the AWS CLI, rather
than a syntax peculiar to kir.

The price is a dependency (13 `go.sum` lines, 0.2 MB of binary, and 1 ms per
1000 documents — 75 ms to 76 ms) and a larger surface: users can now write
expressions that select something which is not a container at all, and nothing
here checks that claim. ADR 0008's reference validation blunts it — since 0.4.4
a selected value that is not a reportable image reference is named on stderr
with a non-zero exit rather than printed — so a mis-aimed expression fails
loudly. What survives is one selecting something that merely *looks* like a
reference.

Precision is exact by construction. A path either matches or it does not, so
there are no false positives to guard against and no schema to keep in step with
the Kubernetes API.

The cost is that **it does not answer #26's second motivation on its own**. A
custom resource stays invisible until somebody describes it: `TestCustomResource`
pins both halves, an Argo Rollout yielding nothing by default and its images
under `--config`. Every user of Argo, Knative, or an in-house CRD has to write
that file, and a kind whose PodSpec moves in a later API version needs it
updated. Options B and C recognise those resources with no configuration at all.

The honest framing is that this is the *complement* of structural discovery
rather than a competitor: precise where it is configured, blind where it is not.
It also composes — structural discovery could use a file like this to override
what it infers, which is roughly how Kyverno's `imageExtractors` work alongside
its built-in knowledge.
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 images at configured per-kind JMESPath expressions (proposed — #26) | 2026-08-09 |
Loading