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
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
5 changes: 4 additions & 1 deletion 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 that carry a
// PodSpec but were missing from the previous hand-written type switch; they
// are understood now because discovery matches on type, not on kind.
kinds := []string{"Pod", "CronJob", "DaemonSet", "Deployment", "Job", "PodTemplate", "ReplicaSet", "ReplicationController", "StatefulSet"}

for _, kind := range kinds {
t.Run(kind, func(t *testing.T) {
Expand Down
37 changes: 37 additions & 0 deletions docs/adr/0009-podspec-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 9. Find PodSpecs by reflecting over the decoded object

- 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.

## Decision

Keep typed decoding. Replace the type switch with a reflective walk of the
decoded Go value that collects every field of type `corev1.PodSpec`, wherever it
sits in the struct.

Kinds and paths both disappear: `k8s.FindPodSpecs` matches on type, so any type
the scheme can decode is understood, and the kind allow-list in `yamlparser`
(previously consulted for `List` items) goes with it.

## Consequences

Two built-in kinds that the type switch omitted now work with no code dedicated
to them — `ReplicationController` and `PodTemplate` — and a future workload kind
added to `k8s.io/api` needs no change here.

Precision is unchanged: only real `corev1.PodSpec` values match, so there are no
false positives, and no measurable cost (decoding dominates; see the pull
request for numbers).

The limit is inherited from 0001 and is the reason this may not be the answer to
#26: reflection can only see what the scheme decodes, so a custom resource
embedding a PodSpec — Argo Rollouts, Knative — is still invisible. This ADR
addresses "less hardcoded kinds/structures" but not "easier to use with other
(custom) resources". Options B (structural matching), C (CUE schema), and D
(configurable paths) trade precision or dependencies for that reach.
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 by reflecting over the decoded object (proposed — #26) | 2026-08-09 |
107 changes: 81 additions & 26 deletions k8s/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,84 @@ package k8s

import (
"fmt"
"reflect"

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

// GetPodSpec extracts the PodSpec from a Kubernetes object
// podSpecType is the type we are hunting for inside decoded objects.
var podSpecType = reflect.TypeOf(corev1.PodSpec{})

// maxDepth bounds the struct walk. The deepest PodSpec in the built-in API is
// CronJob's, at spec.jobTemplate.spec.template.spec — well inside this — so the
// limit only ever guards against a pathological or cyclic type.
const maxDepth = 20

// FindPodSpecs returns every corev1.PodSpec reachable from obj, in declaration
// order.
//
// Rather than enumerating kinds and their PodSpec paths, this walks the decoded
// Go value and matches on type. Any type registered in the client-go scheme is
// therefore understood for free — including ReplicationController and
// PodTemplate, which the previous hand-written type switch omitted — and adding
// a workload kind to k8s.io/api requires no change here.
//
// The trade-off is that this only sees types the scheme can decode: custom
// resources are still invisible. See docs/adr/0009-podspec-discovery.md.
func FindPodSpecs(obj any) []*corev1.PodSpec {
var found []*corev1.PodSpec
walk(reflect.ValueOf(obj), &found, 0)
return found
}

func walk(v reflect.Value, found *[]*corev1.PodSpec, depth int) {
if depth > maxDepth || !v.IsValid() {
return
}

switch v.Kind() {
case reflect.Pointer, reflect.Interface:
if v.IsNil() {
return
}
walk(v.Elem(), found, depth+1)

case reflect.Struct:
if v.Type() == podSpecType {
*found = append(*found, podSpecPointer(v))
return // a PodSpec never contains another PodSpec
}
for i := range v.NumField() {
if !v.Type().Field(i).IsExported() {
continue
}
walk(v.Field(i), found, depth+1)
}

case reflect.Slice, reflect.Array:
for i := range v.Len() {
walk(v.Index(i), found, depth+1)
}
}
}

// podSpecPointer returns v as a *corev1.PodSpec, copying only when v is not
// addressable (a slice or map element reached by value).
func podSpecPointer(v reflect.Value) *corev1.PodSpec {
if v.CanAddr() {
return v.Addr().Interface().(*corev1.PodSpec)
}
spec := v.Interface().(corev1.PodSpec)
return &spec
}

// GetPodSpec returns the first PodSpec in obj, or an error when it has none.
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:
specs := FindPodSpecs(obj)
if len(specs) == 0 {
return nil, fmt.Errorf("object does not have a PodSpec")
}
return specs[0], nil
}

func GetContainerImages(containers []corev1.Container) []string {
Expand All @@ -38,17 +90,20 @@ func GetContainerImages(containers []corev1.Container) []string {
return images
}

// GetContainersFromObject returns the containers of every PodSpec in obj.
func GetContainersFromObject(obj any) ([]corev1.Container, error) {
podSpec, err := GetPodSpec(obj)
if err != nil {
return nil, err
specs := FindPodSpecs(obj)
if len(specs) == 0 {
return nil, fmt.Errorf("object does not have a PodSpec")
}

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))
for _, podSpec := range specs {
containers = append(containers, podSpec.Containers...)
containers = append(containers, podSpec.InitContainers...)
for _, ec := range podSpec.EphemeralContainers {
containers = append(containers, corev1.Container(ec.EphemeralContainerCommon))
}
}
return containers, nil
}
41 changes: 41 additions & 0 deletions k8s/k8s_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,44 @@ func TestGetContainersFromObject(t *testing.T) {
})
}
}

// FindPodSpecs is the discovery primitive: it matches on type, so a kind is
// understood without being named anywhere. These are kinds the previous
// hand-written type switch did not cover.
func TestFindPodSpecsUnlistedKinds(t *testing.T) {
template := corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "c", Image: "image1"}},
},
}

tests := []struct {
name string
obj any
}{
{"PodTemplate", &corev1.PodTemplate{Template: template}},
{"ReplicationController", &corev1.ReplicationController{
Spec: corev1.ReplicationControllerSpec{Template: &template},
}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
specs := FindPodSpecs(tt.obj)
if len(specs) != 1 {
t.Fatalf("expected 1 PodSpec, got %d", len(specs))
}
if got := specs[0].Containers[0].Image; got != "image1" {
t.Errorf("expected image %q, got %q", "image1", got)
}
})
}
}

// An object with no PodSpec anywhere in it yields nothing rather than a false
// positive — Service has a Spec, but not a PodSpec.
func TestFindPodSpecsNone(t *testing.T) {
if specs := FindPodSpecs(&corev1.Service{}); len(specs) != 0 {
t.Errorf("expected no PodSpecs, got %d", len(specs))
}
}
19 changes: 5 additions & 14 deletions yamlparser/yamlparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"slices"

"github.com/mpv/kir/k8s"
corev1 "k8s.io/api/core/v1"
Expand All @@ -16,8 +15,6 @@ import (
"k8s.io/client-go/kubernetes/scheme"
)

var supportedKinds = []string{"Pod", "Deployment", "DaemonSet", "ReplicaSet", "StatefulSet", "Job", "CronJob"}

// ProcessReader reads a (possibly multi-document) YAML stream and returns the
// container images of every supported workload it contains. Documents are
// separated using the Kubernetes YAML reader, which correctly handles leading
Expand Down Expand Up @@ -107,20 +104,14 @@ func ProcessData(data []byte) ([]string, error) {
return nil, nil
}

// processUnstructured handles one item of a List by feeding it back through
// ProcessData, which already skips non-workload and unregistered kinds. There
// is no kind allow-list to consult: whether an item yields images is decided by
// whether its decoded object contains a PodSpec.
func processUnstructured(item unstructured.Unstructured) ([]string, error) {
itemData, err := item.MarshalJSON()
if err != nil {
return nil, fmt.Errorf("error marshaling item: %v", err)
}
gvk := item.GroupVersionKind()
if slices.Contains(supportedKinds, gvk.Kind) {
images, err := ProcessData(itemData)
if err != nil {
return nil, fmt.Errorf("error processing data: %v", err)
}
return images, nil
}
// Non-workload items inside a List are skipped, mirroring how top-level
// non-workload documents are handled.
return nil, nil
return ProcessData(itemData)
}