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
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,33 @@ $ 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) |
| A resource whose images aren't in a `PodSpec` — an Argo `Workflow` | 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.

There is no list of supported kinds. A document yields images if it holds something shaped like a `PodSpec` — matched by decoding it against the Kubernetes API types — so a custom resource that embeds one works without `kir` knowing anything about it.

### Teaching `kir` about a resource it can't infer

Some resources keep images somewhere that isn't a `PodSpec`, so there is no shape to recognise. An Argo `Workflow` is the common case: a list of templates, each holding a container, a script, or neither. Describe those with `--config`, using [JMESPath](https://jmespath.org):

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

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

An entry wins for its kind, so this also *corrects* `kir` where inference gets something wrong — an entry with no expressions silences a kind entirely. Everything not described this way is still inferred, so most manifests need no configuration at all. The built-in entries in [`k8s/resources.yaml`](k8s/resources.yaml) are only a shortcut for the common kinds: delete them and `kir` finds the same images, just more slowly.
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
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][]"]
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: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
builder:1.2.0
python:3.12
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
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: 36 additions & 4 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,10 +53,40 @@ 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 two halves of how kir reads a custom resource.
//
// Rollout is inferred: it embeds a PodSpec, so the walk finds its images with
// no configuration and nothing in kir naming that kind. Workflow has to be
// described: its images sit in bare containers across a list of templates,
// which is not a PodSpec and so has no shape to match — the case configuration
// exists for. Both are pinned, because the pair is the whole argument for
// having both mechanisms.
func TestCustomResource(t *testing.T) {
t.Run("Inferred", func(t *testing.T) {
verify(t, []string{"kir_test.TestCustomResource.input.yaml"}, nil)
})

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

t.Run("WorkflowConfigured", 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
80 changes: 80 additions & 0 deletions docs/adr/0009-podspec-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 9. Infer images structurally, with configured overrides

- Status: **proposed** — a fifth candidate for #26, combining the approaches in #82 and #84; 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 images to be *found* rather than looked up, so
custom resources work too.

Four candidates were raised and measured (#81–#84). Two of them are the ones
that matter, and each fails where the other succeeds:

- **Structural inference** (#82) recognises anything shaped like a PodSpec, with
no configuration — but a resource holding *bare containers* has no PodSpec
shape to match, so an Argo `Workflow` is invisible to it, with no way for a
user to say otherwise.
- **Configured expressions** (#84) reach anything a user can write an expression
for, exactly — but every Argo, Knative or in-house CRD stays invisible until
somebody writes that file, including resources whose shape already speaks for
itself.

## Decision

Do both, with configuration taking precedence per kind.

`Config.FindImages` looks up the document's kind. An entry decides on its own:
its expressions are followed and the walk is not consulted. Everything else is
inferred by the structural walk. The two never both contribute to one document,
so an image cannot be reported twice.

The built-in `resources.yaml` keeps entries for the built-in kinds, but **as an
accelerator, not as knowledge**. Deleting every entry changes no answer, only
speed — `TestBuiltInConfigIsRedundant` pins exactly that, comparing each
built-in kind's configured result against its inferred one. Without that test
`resources.yaml` would quietly become the hardcoded kind list #26 set out to
remove.

## Consequences

The union of both reaches, and one capability neither has alone:

- Built-in workloads, and custom resources embedding a PodSpec (Argo `Rollout`),
need **no configuration** — inferred.
- Resources holding bare containers (Argo `Workflow`) are reachable, which
inference alone cannot do.
- An entry with **no expressions silences a kind**, so a user can overrule the
walk when it reads something wrongly. Inference alone cannot be told to
ignore; configuration alone has nothing to ignore.

It is also **cheaper than inference alone on typical input**: with the built-ins
configured, ordinary manifests take the exact lookup and the walk never runs.
Over 1000 Deployments, 107 ms — against 171 ms for inference alone (#82), and
near configuration alone's 92 ms (#84). Only kinds nobody has described pay for
the walk, which is the reverse of the usual cost of combining two mechanisms.

The costs:

- **Two mechanisms** to document and reason about, where each of #82 and #84 has
one. The precedence rule is the whole of the extra contract, but it is a
contract.
- `k8s.io/api` (the inference schema) *and* `go-jmespath` (the expression
engine): 12.6 MB and 78 `go.sum` lines. Larger than configuration alone
(4.1 MB, 42) and barely above inference alone (12.4 MB, 72); still less than
half of today's 27.2 MB.
- Precision is uneven by design. The walk validates shape against the Kubernetes
types and rejects lookalikes; a configured expression is taken at its word,
and nothing checks that what it selects is a container. ADR 0008's reference
validation blunts this — 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 instead of silently. What survives is
an expression selecting something that merely *looks* like a reference.

This also makes the "seen but not detected" warning planned in #75 both rare and
actionable for the first time: a document that is neither configured nor yields
anything from the walk is precisely the case worth reporting, and `--config` is
the remedy to point the user at. Under inference alone the warning has no
remedy; under configuration alone nearly every custom resource trips it.
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) | Infer images structurally, with configured overrides (proposed — #26) | 2026-08-09 |
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ go 1.26.0
require (
github.com/approvals/go-approval-tests v1.14.0
github.com/distribution/reference v0.6.0
github.com/jmespath/go-jmespath v0.4.0
k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
sigs.k8s.io/yaml v1.6.0
)

require (
Expand All @@ -29,5 +30,4 @@ require (
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
Loading