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: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,15 @@ $ 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 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.

So stdout carries only images and stderr stays quiet for normal input. See [ADR 0007](docs/adr/0007-document-classification.md) for the rationale.
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
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.
24 changes: 19 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
58 changes: 58 additions & 0 deletions docs/adr/0009-podspec-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 9. Find PodSpecs structurally, validated against the Kubernetes Go 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,
so that custom resources embedding one work too.

## Decision

Stop decoding into typed Kubernetes objects. Decode each document into plain Go
values and walk it, testing each node for PodSpec shape.

The shape test is the load-bearing part, and it is not a field-name heuristic: a
candidate's `containers` / `initContainers` / `ephemeralContainers` are decoded
into the real `corev1` types with unknown fields rejected. The Kubernetes Go
types are the schema. A node matches when Kubernetes itself would call it a
PodSpec.

Documents are still required to carry a `kind`, which keeps kir aimed at
manifests rather than at any YAML containing something image-like.

## Consequences

Custom resources work — an Argo `Rollout` yields its images with nothing in kir
naming that kind — and so do the built-ins the old list omitted
(`ReplicationController`, `PodTemplate`). `List` stops being a special case: its
items are just more nodes, so the kind allow-list and the unstructured item
handling both go.

Dropping typed decoding drops `k8s.io/client-go` entirely: the binary goes from
27.2 MB to 12.4 MB. `k8s.io/api` stays, as the schema.

The costs, and they are real:

- **Slower**, since every candidate node is decode-tested: 105 ms → 133 ms over
1000 documents (~28 µs per document). Acceptable for a tool that shells out to
an image scanner afterwards.
- **Precision now rests on the strict decode.** A field named `containers`
holding anything else is rejected, and there is a golden fixture
(`TestSkipsNonWorkloads.Lookalike`) to keep it that way. But a custom resource
that inlines a PodSpec *alongside* its own fields would fail the strict decode
and be missed — the failure mode moves from "kind not listed" to "shape not
matched".
- **Bound to the vendored `k8s.io/api`**: a container field newer than the
vendored version fails the strict decode. Bumping the dependency is the fix,
and a stale bump is now a correctness issue rather than only a hygiene one.
In practice the binding is loose — the v0.32.3 → v0.36.3 bump moved no
goldens and needed no code change — but it is the thing to watch.

Alternatives considered: reflecting over the typed scheme (option A) keeps
perfect precision but cannot see custom resources at all; a CUE schema (option
C) buys a user-editable schema for a large dependency; configurable per-kind
paths (option D) keep precision but push the work onto users.
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 structurally, validated against the Kubernetes Go types (proposed — #26) | 2026-08-09 |
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/distribution/reference v0.6.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 +29,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
)
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
Expand Down
124 changes: 92 additions & 32 deletions k8s/k8s.go
Original file line number Diff line number Diff line change
@@ -1,54 +1,114 @@
// Package k8s finds container images in Kubernetes manifests that have been
// decoded into plain Go values.
package k8s

import (
"fmt"
"encoding/json"
"maps"
"slices"

appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)

// GetPodSpec extracts the PodSpec from a Kubernetes object
func GetPodSpec(obj any) (*corev1.PodSpec, error) {
switch resource := obj.(type) {
case *corev1.Pod:
return &resource.Spec, nil
case *appsv1.Deployment:
return &resource.Spec.Template.Spec, nil
case *appsv1.DaemonSet:
return &resource.Spec.Template.Spec, nil
case *appsv1.ReplicaSet:
return &resource.Spec.Template.Spec, nil
case *appsv1.StatefulSet:
return &resource.Spec.Template.Spec, nil
case *batchv1.Job:
return &resource.Spec.Template.Spec, nil
case *batchv1.CronJob:
return &resource.Spec.JobTemplate.Spec.Template.Spec, nil
default:
return nil, fmt.Errorf("object does not have a PodSpec")
// containerFields are the PodSpec fields that carry images, listed in the order
// kir reports them.
var containerFields = []string{"containers", "initContainers", "ephemeralContainers"}

// maxDepth bounds the walk. Real manifests nest a handful of levels; the limit
// only guards against absurd input.
const maxDepth = 100

// FindImages returns the images of every PodSpec-shaped node reachable from
// doc, a manifest decoded into plain Go values (maps, slices, scalars).
//
// Nothing here knows what a Deployment is. The walk descends until it meets a
// node shaped like a PodSpec, which is why a custom resource that embeds one is
// understood on the same footing as a built-in workload — and why a List needs
// no special case, its items being just more nodes.
func FindImages(doc any) []string {
var images []string
find(doc, &images, 0)
return images
}

func find(node any, images *[]string, depth int) {
if depth > maxDepth {
return
}

switch n := node.(type) {
case map[string]any:
if found, ok := podSpecImages(n); ok {
*images = append(*images, found...)
return // a PodSpec does not contain another PodSpec
}
// Sorted, so output order depends on the manifest rather than on Go's
// randomised map iteration.
for _, key := range slices.Sorted(maps.Keys(n)) {
find(n[key], images, depth+1)
}
case []any:
for _, item := range n {
find(item, images, depth+1)
}
}
}

func GetContainerImages(containers []corev1.Container) []string {
// podSpecImages reports whether node is PodSpec-shaped, and if so its images.
//
// The test is not "has a field called containers" but "does that field decode
// into the real corev1 type, rejecting unknown fields". The Kubernetes Go types
// are the schema, so a custom resource embedding a genuine PodSpec matches,
// while a lookalike — a field named containers holding something else — does
// not.
func podSpecImages(node map[string]any) ([]string, bool) {
var images []string
for _, container := range containers {
images = append(images, container.Image)
matched := false

for _, field := range containerFields {
value, ok := node[field]
if !ok {
continue
}
containers, err := decodeContainers(field, value)
if err != nil || len(containers) == 0 {
continue
}
matched = true
for _, container := range containers {
if container.Image != "" {
images = append(images, container.Image)
}
}
}
return images

return images, matched
}

func GetContainersFromObject(obj any) ([]corev1.Container, error) {
podSpec, err := GetPodSpec(obj)
// decodeContainers strictly decodes one container list into its corev1 type.
// An error means "not that type", which is the signal the walk needs.
func decodeContainers(field string, value any) ([]corev1.Container, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}

if field == "ephemeralContainers" {
var ephemeral []corev1.EphemeralContainer
if err := yaml.UnmarshalStrict(data, &ephemeral); err != nil {
return nil, err
}
containers := make([]corev1.Container, 0, len(ephemeral))
for _, ec := range ephemeral {
containers = append(containers, corev1.Container(ec.EphemeralContainerCommon))
}
return containers, nil
}

var containers []corev1.Container
containers = append(containers, podSpec.Containers...)
containers = append(containers, podSpec.InitContainers...)
for _, ec := range podSpec.EphemeralContainers {
containers = append(containers, corev1.Container(ec.EphemeralContainerCommon))
if err := yaml.UnmarshalStrict(data, &containers); err != nil {
return nil, err
}
return containers, nil
}
Loading