diff --git a/README.md b/README.md index a90d997..b9ddc44 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/approvals/kir_test.TestCLI.Usage.stderr.approved.txt b/approvals/kir_test.TestCLI.Usage.stderr.approved.txt index f4c597f..e78f7b8 100644 --- a/approvals/kir_test.TestCLI.Usage.stderr.approved.txt +++ b/approvals/kir_test.TestCLI.Usage.stderr.approved.txt @@ -1 +1 @@ -Usage: kir [ ...] | kir - | kir --version +Usage: kir [--config ] [ ...] | kir - | kir --version diff --git a/approvals/kir_test.TestCustomResource.Configured.exitcode.approved.txt b/approvals/kir_test.TestCustomResource.Configured.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Configured.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestCustomResource.Configured.stderr.approved.txt b/approvals/kir_test.TestCustomResource.Configured.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestCustomResource.Configured.stdout.approved.txt b/approvals/kir_test.TestCustomResource.Configured.stdout.approved.txt new file mode 100644 index 0000000..0815858 --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Configured.stdout.approved.txt @@ -0,0 +1,2 @@ +my-registry/app:1.4.2 +busybox:1.36 diff --git a/approvals/kir_test.TestCustomResource.Undescribed.exitcode.approved.txt b/approvals/kir_test.TestCustomResource.Undescribed.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Undescribed.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestCustomResource.Undescribed.stderr.approved.txt b/approvals/kir_test.TestCustomResource.Undescribed.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestCustomResource.Undescribed.stdout.approved.txt b/approvals/kir_test.TestCustomResource.Undescribed.stdout.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestCustomResource.Workflow.config.yaml b/approvals/kir_test.TestCustomResource.Workflow.config.yaml new file mode 100644 index 0000000..9d4ec66 --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Workflow.config.yaml @@ -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][]"] diff --git a/approvals/kir_test.TestCustomResource.Workflow.exitcode.approved.txt b/approvals/kir_test.TestCustomResource.Workflow.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Workflow.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestCustomResource.Workflow.input.yaml b/approvals/kir_test.TestCustomResource.Workflow.input.yaml new file mode 100644 index 0000000..93a0dd7 --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Workflow.input.yaml @@ -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: {} diff --git a/approvals/kir_test.TestCustomResource.Workflow.stderr.approved.txt b/approvals/kir_test.TestCustomResource.Workflow.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestCustomResource.Workflow.stdout.approved.txt b/approvals/kir_test.TestCustomResource.Workflow.stdout.approved.txt new file mode 100644 index 0000000..95aac7e --- /dev/null +++ b/approvals/kir_test.TestCustomResource.Workflow.stdout.approved.txt @@ -0,0 +1,2 @@ +builder:1.2.0 +python:3.12 diff --git a/approvals/kir_test.TestCustomResource.config.yaml b/approvals/kir_test.TestCustomResource.config.yaml new file mode 100644 index 0000000..6925c59 --- /dev/null +++ b/approvals/kir_test.TestCustomResource.config.yaml @@ -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] diff --git a/approvals/kir_test.TestCustomResource.input.yaml b/approvals/kir_test.TestCustomResource.input.yaml new file mode 100644 index 0000000..0942193 --- /dev/null +++ b/approvals/kir_test.TestCustomResource.input.yaml @@ -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 diff --git a/approvals/kir_test.TestFailure.BadYAML.stderr.approved.txt b/approvals/kir_test.TestFailure.BadYAML.stderr.approved.txt index 49591ae..d43cd99 100644 --- a/approvals/kir_test.TestFailure.BadYAML.stderr.approved.txt +++ b/approvals/kir_test.TestFailure.BadYAML.stderr.approved.txt @@ -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 ']' diff --git a/approvals/kir_test.TestFailure.PartialStream.stderr.approved.txt b/approvals/kir_test.TestFailure.PartialStream.stderr.approved.txt index 49591ae..d43cd99 100644 --- a/approvals/kir_test.TestFailure.PartialStream.stderr.approved.txt +++ b/approvals/kir_test.TestFailure.PartialStream.stderr.approved.txt @@ -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 ']' diff --git a/approvals/kir_test.TestKind.PodTemplate.exitcode.approved.txt b/approvals/kir_test.TestKind.PodTemplate.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestKind.PodTemplate.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestKind.PodTemplate.input.yaml b/approvals/kir_test.TestKind.PodTemplate.input.yaml new file mode 100644 index 0000000..09b29a7 --- /dev/null +++ b/approvals/kir_test.TestKind.PodTemplate.input.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: PodTemplate +metadata: + name: tmpl +template: + spec: + containers: + - name: worker + image: worker:3.1 diff --git a/approvals/kir_test.TestKind.PodTemplate.stderr.approved.txt b/approvals/kir_test.TestKind.PodTemplate.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestKind.PodTemplate.stdout.approved.txt b/approvals/kir_test.TestKind.PodTemplate.stdout.approved.txt new file mode 100644 index 0000000..94a335b --- /dev/null +++ b/approvals/kir_test.TestKind.PodTemplate.stdout.approved.txt @@ -0,0 +1 @@ +worker:3.1 diff --git a/approvals/kir_test.TestKind.ReplicationController.exitcode.approved.txt b/approvals/kir_test.TestKind.ReplicationController.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestKind.ReplicationController.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestKind.ReplicationController.input.yaml b/approvals/kir_test.TestKind.ReplicationController.input.yaml new file mode 100644 index 0000000..b24833e --- /dev/null +++ b/approvals/kir_test.TestKind.ReplicationController.input.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ReplicationController +metadata: + name: legacy +spec: + replicas: 2 + template: + spec: + containers: + - name: web + image: nginx:1.27 diff --git a/approvals/kir_test.TestKind.ReplicationController.stderr.approved.txt b/approvals/kir_test.TestKind.ReplicationController.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestKind.ReplicationController.stdout.approved.txt b/approvals/kir_test.TestKind.ReplicationController.stdout.approved.txt new file mode 100644 index 0000000..20a9c2f --- /dev/null +++ b/approvals/kir_test.TestKind.ReplicationController.stdout.approved.txt @@ -0,0 +1 @@ +nginx:1.27 diff --git a/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.exitcode.approved.txt b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.exitcode.approved.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.exitcode.approved.txt @@ -0,0 +1 @@ +0 diff --git a/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.input.yaml b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.input.yaml new file mode 100644 index 0000000..f66e324 --- /dev/null +++ b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.input.yaml @@ -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 diff --git a/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.stderr.approved.txt b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.stderr.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.stdout.approved.txt b/approvals/kir_test.TestSkipsNonWorkloads.Lookalike.stdout.approved.txt new file mode 100644 index 0000000..e69de29 diff --git a/approvals/kir_test.go b/approvals/kir_test.go index 04effb3..6c1f464 100644 --- a/approvals/kir_test.go +++ b/approvals/kir_test.go @@ -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) { @@ -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) }) } diff --git a/cmd/cmd.go b/cmd/cmd.go index 3dea33e..2f3af0b 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -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" ) @@ -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 [ ...] | kir - | kir --version") + logger.Print("Usage: kir [--config ] [ ...] | kir - | kir --version") + return 1 + } + + args, config, err := configFlag(args) + if err != nil { + logger.Printf("error: %v", err) return 1 } @@ -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 { @@ -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. @@ -77,6 +85,30 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } +// configFlag consumes a leading `--config ` 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 diff --git a/docs/adr/0009-podspec-discovery.md b/docs/adr/0009-podspec-discovery.md new file mode 100644 index 0000000..3cd3552 --- /dev/null +++ b/docs/adr/0009-podspec-discovery.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6dd9e53..ff788e4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/go.mod b/go.mod index bf15914..f581ef8 100644 --- a/go.mod +++ b/go.mod @@ -5,29 +5,15 @@ go 1.26.0 require ( github.com/approvals/go-approval-tests v1.14.0 github.com/distribution/reference v0.6.0 - k8s.io/api v0.36.3 + github.com/jmespath/go-jmespath v0.4.0 k8s.io/apimachinery v0.36.3 - k8s.io/client-go v0.36.3 + sigs.k8s.io/yaml v1.6.0 ) require ( - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/text v0.33.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 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 ) diff --git a/go.sum b/go.sum index c7f6c0f..a2be363 100644 --- a/go.sum +++ b/go.sum @@ -2,77 +2,45 @@ github.com/approvals/go-approval-tests v1.14.0 h1:TVCaKX2PR5m0xN3062cW9PC5Ptkx3k github.com/approvals/go-approval-tests v1.14.0/go.mod h1:3HKg6haD0Wg6p1SiA8/xHWg/xu4qnsB73ocJoo6zNy8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= -k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= -sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/k8s/k8s.go b/k8s/k8s.go index 1e88e2f..b874d64 100644 --- a/k8s/k8s.go +++ b/k8s/k8s.go @@ -1,54 +1,213 @@ +// Package k8s finds container images in Kubernetes manifests that have been +// decoded into plain Go values. Where a kind keeps its containers is +// configuration (resources.yaml), not Go code. package k8s import ( + _ "embed" "fmt" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" + "github.com/jmespath/go-jmespath" + "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") - } -} - -func GetContainerImages(containers []corev1.Container) []string { +//go:embed resources.yaml +var defaultResources string + +// containerFields are the PodSpec fields that carry images, listed in the order +// kir reports them. +var containerFields = []string{"containers", "initContainers", "ephemeralContainers"} + +// Resource says where one kind keeps its images, as JMESPath expressions. +type Resource struct { + Kind string `json:"kind"` + // PodSpecs select PodSpec-shaped nodes, whose container fields are read. + PodSpecs []string `json:"podSpecs,omitempty"` + // Containers select containers directly, for resources that hold bare + // containers rather than a PodSpec. + Containers []string `json:"containers,omitempty"` + // Documents select whole objects, each processed in its own right. A List + // uses this to reach its items. + Documents []string `json:"documents,omitempty"` +} + +// Config maps kinds to where they keep their images. +type Config struct { + Resources []Resource `json:"resources"` + + byKind map[string]expressions +} + +// expressions holds one resource's compiled queries. +type expressions struct { + podSpecs []*jmespath.JMESPath + containers []*jmespath.JMESPath + documents []*jmespath.JMESPath +} + +// LoadConfig parses a resource configuration and compiles its expressions. A +// malformed expression is an error here rather than a path that silently +// matches nothing later. +func LoadConfig(data []byte) (*Config, error) { + var config Config + if err := yaml.UnmarshalStrict(data, &config); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + if err := config.compile(); err != nil { + return nil, err + } + return &config, nil +} + +// DefaultConfig returns the built-in configuration. +func DefaultConfig() *Config { + config, err := LoadConfig([]byte(defaultResources)) + if err != nil { + // The embedded config is loaded in tests; a failure here is a bug. + panic(fmt.Sprintf("embedded config does not load: %v", err)) + } + return config +} + +// Merge returns a copy of c with other's entries applied over it. An entry for +// a kind already configured replaces it, so a user can correct a built-in as +// well as add a custom resource. +func (c *Config) Merge(other *Config) *Config { + merged := &Config{Resources: append(append([]Resource{}, c.Resources...), other.Resources...)} + // Both halves compiled when they were loaded, so this cannot fail. + if err := merged.compile(); err != nil { + panic(fmt.Sprintf("merging already-compiled configs: %v", err)) + } + return merged +} + +func (c *Config) compile() error { + c.byKind = make(map[string]expressions, len(c.Resources)) + for _, resource := range c.Resources { + var compiled expressions + var err error + if compiled.podSpecs, err = compileAll(resource.Kind, "podSpecs", resource.PodSpecs); err != nil { + return err + } + if compiled.containers, err = compileAll(resource.Kind, "containers", resource.Containers); err != nil { + return err + } + if compiled.documents, err = compileAll(resource.Kind, "documents", resource.Documents); err != nil { + return err + } + c.byKind[resource.Kind] = compiled + } + return nil +} + +func compileAll(kind, field string, exprs []string) ([]*jmespath.JMESPath, error) { + compiled := make([]*jmespath.JMESPath, 0, len(exprs)) + for _, expr := range exprs { + parsed, err := jmespath.Compile(expr) + if err != nil { + return nil, fmt.Errorf("%s.%s: %q: %w", kind, field, expr, err) + } + compiled = append(compiled, parsed) + } + return compiled, nil +} + +// Kinds returns the configured kinds, for diagnostics. +func (c *Config) Kinds() []string { + kinds := make([]string, 0, len(c.Resources)) + for _, resource := range c.Resources { + kinds = append(kinds, resource.Kind) + } + return kinds +} + +// FindImages returns the images of doc, a manifest decoded into plain Go +// values. A kind with no entry in the configuration yields nothing. +func (c *Config) FindImages(doc map[string]any) []string { + kind, _ := doc["kind"].(string) + resource, ok := c.byKind[kind] + if !ok { + return nil + } + var images []string - for _, container := range containers { - images = append(images, container.Image) + for _, expr := range resource.podSpecs { + for _, node := range search(expr, doc) { + images = append(images, podSpecImages(node)...) + } + } + for _, expr := range resource.containers { + for _, node := range search(expr, doc) { + images = append(images, containerImages(node)...) + } + } + for _, expr := range resource.documents { + for _, node := range search(expr, doc) { + if nested, ok := node.(map[string]any); ok { + images = append(images, c.FindImages(nested)...) + } + } } return images } -func GetContainersFromObject(obj any) ([]corev1.Container, error) { - podSpec, err := GetPodSpec(obj) - if err != nil { - return nil, err +// search runs one expression and returns the nodes it selected. +// +// A JMESPath projection yields one entry per input element, null where the +// field is absent — `spec.templates[*].container` over templates that hold no +// container, say — so nulls are dropped rather than counted as matches. An +// expression selecting nothing returns no nodes, the normal case for a path +// that does not apply to this document. +func search(expr *jmespath.JMESPath, doc any) []any { + result, err := expr.Search(doc) + if err != nil || result == nil { + return 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)) + list, ok := result.([]any) + if !ok { + return []any{result} + } + + nodes := make([]any, 0, len(list)) + for _, node := range list { + if node != nil { + nodes = append(nodes, node) + } + } + return nodes +} + +// podSpecImages reads the images out of a node the configuration has declared +// to be a PodSpec. Nothing here checks that claim: precision comes from the +// configuration being right. +func podSpecImages(node any) []string { + podSpec, ok := node.(map[string]any) + if !ok { + return nil + } + + var images []string + for _, field := range containerFields { + images = append(images, containerImages(podSpec[field])...) + } + return images +} + +// containerImages reads the image of a container, or of every container in a +// list of them. +func containerImages(node any) []string { + switch n := node.(type) { + case []any: + var images []string + for _, item := range n { + images = append(images, containerImages(item)...) + } + return images + case map[string]any: + if image, ok := n["image"].(string); ok && image != "" { + return []string{image} + } } - return containers, nil + return nil } diff --git a/k8s/k8s_test.go b/k8s/k8s_test.go index a7c5422..0700f86 100644 --- a/k8s/k8s_test.go +++ b/k8s/k8s_test.go @@ -1,274 +1,293 @@ package k8s import ( + "slices" + "strings" "testing" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" ) -// Test that GetPodSpec works for the kinds that have a PodSpec: -func TestGetPodSpec(t *testing.T) { +// decode turns a manifest into the plain Go values FindImages reads. +func decode(t *testing.T, manifest string) map[string]any { + t.Helper() + var doc map[string]any + if err := yaml.Unmarshal([]byte(manifest), &doc); err != nil { + t.Fatalf("decoding fixture: %v", err) + } + return doc +} + +func TestDefaultConfigParses(t *testing.T) { + config := DefaultConfig() + if len(config.Kinds()) == 0 { + t.Fatal("embedded config describes no kinds") + } +} + +// Each configured kind is reached by following its declared path. +func TestFindImagesConfiguredKinds(t *testing.T) { tests := []struct { - name string - obj any - wantErr bool + name string + manifest string + want []string }{ - {"Pod", &corev1.Pod{}, false}, - {"Deployment", &appsv1.Deployment{}, false}, - {"DaemonSet", &appsv1.DaemonSet{}, false}, - {"ReplicaSet", &appsv1.ReplicaSet{}, false}, - {"StatefulSet", &appsv1.StatefulSet{}, false}, - {"Job", &batchv1.Job{}, false}, - {"CronJob", &batchv1.CronJob{}, false}, - {"Invalid", "invalid", true}, + { + name: "Pod (PodSpec directly on spec)", + manifest: ` +kind: Pod +spec: + containers: + - name: app + image: app:1 +`, + want: []string{"app:1"}, + }, + { + name: "Deployment (PodSpec under a template)", + manifest: ` +kind: Deployment +spec: + template: + spec: + containers: + - name: app + image: app:2 +`, + want: []string{"app:2"}, + }, + { + name: "CronJob (PodSpec four levels down)", + manifest: ` +kind: CronJob +spec: + jobTemplate: + spec: + template: + spec: + containers: + - name: app + image: app:3 +`, + want: []string{"app:3"}, + }, + { + name: "List (documents path expands items)", + manifest: ` +kind: List +items: +- kind: Pod + spec: + containers: + - name: a + image: a:1 +- kind: Service + spec: + ports: + - port: 80 +- kind: Pod + spec: + containers: + - name: b + image: b:1 +`, + want: []string{"a:1", "b:1"}, + }, + { + name: "all three container fields, in report order", + manifest: ` +kind: Pod +spec: + containers: + - name: app + image: app:1 + initContainers: + - name: init + image: init:1 + ephemeralContainers: + - name: debugger + image: debug:1 + targetContainerName: app +`, + want: []string{"app:1", "init:1", "debug:1"}, + }, + { + name: "a configured kind whose path does not apply", + manifest: ` +kind: Pod +status: + containerStatuses: + - name: app + image: app:1 +`, + want: nil, + }, + { + name: "an unconfigured kind", + manifest: ` +kind: Service +spec: + ports: + - port: 80 +`, + want: nil, + }, } + config := DefaultConfig() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := GetPodSpec(tt.obj) - if (err != nil) != tt.wantErr { - t.Errorf("GetPodSpec() error = %v, wantErr %v", err, tt.wantErr) + got := config.FindImages(decode(t, tt.manifest)) + if !slices.Equal(got, tt.want) { + t.Errorf("FindImages() = %v, want %v", got, tt.want) } }) } } -// Test that GetPodSpec returns the correct PodSpec: -func TestGetPodSpecPod(t *testing.T) { - pod := &corev1.Pod{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - Image: "test-image", - }, - }, - }, - } +// A custom resource is invisible until it is described, and then it is read +// exactly like a built-in. This is the whole trade of this approach. +func TestFindImagesCustomResourceNeedsConfig(t *testing.T) { + rollout := decode(t, ` +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +spec: + template: + spec: + containers: + - name: app + image: app:1.4.2 +`) - spec, err := GetPodSpec(pod) - if err != nil { - t.Fatalf("GetPodSpec() error = %v", err) + if got := DefaultConfig().FindImages(rollout); len(got) != 0 { + t.Errorf("undescribed Rollout = %v, want no images", got) } - if len(spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(spec.Containers)) + extra, err := LoadConfig([]byte(` +resources: + - kind: Rollout + podSpecs: [spec.template.spec] +`)) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) } - if spec.Containers[0].Image != "test-image" { - t.Errorf("expected image %q, got %q", "test-image", spec.Containers[0].Image) + want := []string{"app:1.4.2"} + if got := DefaultConfig().Merge(extra).FindImages(rollout); !slices.Equal(got, want) { + t.Errorf("described Rollout = %v, want %v", got, want) } } -// Test that GetPodSpec returns the correct PodSpec for all supported kinds: -func TestGetPodSpecSupported(t *testing.T) { - commonPodSpec := corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - Image: "test-image", - }, - }, - } +// Merging replaces a kind's paths, so a built-in entry can be corrected and not +// merely extended. +func TestMergeReplacesPaths(t *testing.T) { + pod := decode(t, ` +kind: Pod +elsewhere: + containers: + - name: app + image: app:1 +`) - tests := []struct { - name string - obj any - }{ - {"Deployment", &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }}, - {"DaemonSet", &appsv1.DaemonSet{ - Spec: appsv1.DaemonSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }}, - {"ReplicaSet", &appsv1.ReplicaSet{ - Spec: appsv1.ReplicaSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }}, - {"StatefulSet", &appsv1.StatefulSet{ - Spec: appsv1.StatefulSetSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }}, - {"Job", &batchv1.Job{ - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }}, - {"CronJob", &batchv1.CronJob{ - Spec: batchv1.CronJobSpec{ - JobTemplate: batchv1.JobTemplateSpec{ - Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{ - Spec: commonPodSpec, - }, - }, - }, - }, - }}, + extra, err := LoadConfig([]byte(` +resources: + - kind: Pod + podSpecs: [elsewhere] +`)) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - spec, err := GetPodSpec(tt.obj) - if err != nil { - t.Fatalf("GetPodSpec() error = %v", err) - } - - if len(spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(spec.Containers)) - } - - if spec.Containers[0].Image != "test-image" { - t.Errorf("expected image %q, got %q", "test-image", spec.Containers[0].Image) - } - }) + want := []string{"app:1"} + if got := DefaultConfig().Merge(extra).FindImages(pod); !slices.Equal(got, want) { + t.Errorf("FindImages() = %v, want %v", got, want) } } -// Test that GetPodSpec fails for an object that does not have a PodSpec: -func TestGetPodSpecInvalid(t *testing.T) { - _, err := GetPodSpec("invalid") - - // Assert correct error message ()"object does not have a PodSpec"): - if err == nil || err.Error() != "object does not have a PodSpec" { - t.Fatalf("GetPodSpec() error = %v, want %q", err, "object does not have a PodSpec") +// A path that runs off the end of the document yields nothing rather than +// panicking, which is the normal case for an optional field. +func TestResolveMissingPath(t *testing.T) { + doc := decode(t, ` +kind: Deployment +spec: {} +`) + if got := DefaultConfig().FindImages(doc); len(got) != 0 { + t.Errorf("FindImages() = %v, want no images", got) } } -func TestGetContainerImages(t *testing.T) { - containers := []corev1.Container{ - {Name: "container1", Image: "image1"}, - {Name: "container2", Image: "image2"}, +func TestLoadConfigRejectsUnknownFields(t *testing.T) { + if _, err := LoadConfig([]byte("resources:\n - kind: Pod\n podSpec: [spec]\n")); err == nil { + t.Error("LoadConfig() accepted an unknown field, want an error") } +} - expected := []string{"image1", "image2"} - images := GetContainerImages(containers) - - if len(images) != len(expected) { - t.Fatalf("expected %d images, got %d", len(expected), len(images)) +// Expressions are compiled when the config loads, so a typo is an error at that +// point rather than a path that silently matches nothing for the rest of the +// run. This is the main safety gain over plain field paths. +func TestLoadConfigRejectsBadExpression(t *testing.T) { + _, err := LoadConfig([]byte("resources:\n - kind: Pod\n podSpecs: [\"spec[\"]\n")) + if err == nil { + t.Fatal("LoadConfig() accepted a malformed expression, want an error") } - - for i, img := range images { - if img != expected[i] { - t.Errorf("expected image %q, got %q", expected[i], img) - } + if !strings.Contains(err.Error(), "Pod.podSpecs") { + t.Errorf("error = %q, want it to name the offending kind and field", err) } } -func TestGetContainersFromObject(t *testing.T) { +// A resource can hold bare containers rather than a PodSpec. An Argo Workflow +// is the common case: a list of templates, each with a container, a script, or +// neither. +func TestFindImagesContainersExpression(t *testing.T) { + workflow := decode(t, ` +kind: Workflow +spec: + templates: + - name: build + container: + image: builder:1 + - name: report + script: + image: python:3.12 + - name: fanout + dag: + tasks: + - name: a +`) + tests := []struct { - name string - obj any - want []corev1.Container - wantErr bool + name string + expr string + want []string }{ { - name: "Pod", - obj: &corev1.Pod{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "container1", Image: "image1"}, - }, - InitContainers: []corev1.Container{ - {Name: "init-container1", Image: "init-image1"}, - }, - }, - }, - want: []corev1.Container{ - {Name: "container1", Image: "image1"}, - {Name: "init-container1", Image: "init-image1"}, - }, - wantErr: false, - }, - { - name: "Deployment", - obj: &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "container1", Image: "image1"}, - }, - InitContainers: []corev1.Container{ - {Name: "init-container1", Image: "init-image1"}, - }, - }, - }, - }, - }, - want: []corev1.Container{ - {Name: "container1", Image: "image1"}, - {Name: "init-container1", Image: "init-image1"}, - }, - wantErr: false, + // A projection drops the templates with no container rather than + // yielding nulls for them. + name: "projection skips templates without the field", + expr: "spec.templates[*].container", + want: []string{"builder:1"}, }, { - name: "Pod with ephemeral container", - obj: &corev1.Pod{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "container1", Image: "image1"}, - }, - InitContainers: []corev1.Container{ - {Name: "init-container1", Image: "init-image1"}, - }, - EphemeralContainers: []corev1.EphemeralContainer{ - { - EphemeralContainerCommon: corev1.EphemeralContainerCommon{ - Name: "debugger", - Image: "ephemeral-image1", - }, - }, - }, - }, - }, - want: []corev1.Container{ - {Name: "container1", Image: "image1"}, - {Name: "init-container1", Image: "init-image1"}, - {Name: "debugger", Image: "ephemeral-image1"}, - }, - wantErr: false, + // Both shapes in one expression — a multi-select, flattened. + name: "multi-select collects both shapes", + expr: "spec.templates[*].[container, script][]", + want: []string{"builder:1", "python:3.12"}, }, { - name: "Invalid", - obj: "invalid", - want: nil, - wantErr: true, + name: "filter selects a subset", + expr: "spec.templates[?name != 'report'].[container, script][]", + want: []string{"builder:1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := GetContainersFromObject(tt.obj) - if (err != nil) != tt.wantErr { - t.Errorf("GetContainersFromObject() error = %v, wantErr %v", err, tt.wantErr) - return - } - if len(got) != len(tt.want) { - t.Errorf("expected %d containers, got %d", len(tt.want), len(got)) + config, err := LoadConfig([]byte("resources:\n - kind: Workflow\n containers: [\"" + tt.expr + "\"]\n")) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) } - for i, container := range got { - if container.Name != tt.want[i].Name || container.Image != tt.want[i].Image { - t.Errorf("expected container %v, got %v", tt.want[i], container) - } + if got := config.FindImages(workflow); !slices.Equal(got, tt.want) { + t.Errorf("FindImages() = %v, want %v", got, tt.want) } }) } diff --git a/k8s/resources.yaml b/k8s/resources.yaml new file mode 100644 index 0000000..2ed29c3 --- /dev/null +++ b/k8s/resources.yaml @@ -0,0 +1,48 @@ +# Where each kind keeps its images. +# +# This is the whole of kir's knowledge about Kubernetes: a kind, and JMESPath +# expressions locating the images inside it. Nothing is inferred — a kind absent +# from this file yields no images. +# +# podSpecs select PodSpec-shaped nodes; their containers, initContainers +# and ephemeralContainers are read. +# containers select containers directly, for resources holding bare +# containers rather than a PodSpec. +# documents select whole objects, each processed in its own right, which is +# how a List is unwrapped. +# +# Expressions are compiled when the file loads, so a malformed one is an error +# rather than a path that silently matches nothing. +# +# Add your own with --config; entries are merged over these, so naming a kind +# listed here replaces its expressions. +resources: + - kind: Pod + podSpecs: [spec] + + - kind: PodTemplate + podSpecs: [template.spec] + + - kind: ReplicationController + podSpecs: [spec.template.spec] + + - kind: Deployment + podSpecs: [spec.template.spec] + + - kind: DaemonSet + podSpecs: [spec.template.spec] + + - kind: ReplicaSet + podSpecs: [spec.template.spec] + + - kind: StatefulSet + podSpecs: [spec.template.spec] + + - kind: Job + podSpecs: [spec.template.spec] + + - kind: CronJob + podSpecs: [spec.jobTemplate.spec.template.spec] + + - kind: List + documents: ["items[*]"] diff --git a/processor/processor.go b/processor/processor.go index 9533a33..d8e9cd1 100644 --- a/processor/processor.go +++ b/processor/processor.go @@ -5,21 +5,22 @@ import ( "io" "os" + "github.com/mpv/kir/k8s" "github.com/mpv/kir/yamlparser" ) // ProcessStdin reads a (possibly multi-document) manifest stream from r and // returns its images. Taking an io.Reader keeps it testable and lets the CLI // inject stdin. -func ProcessStdin(r io.Reader) ([]string, error) { - return yamlparser.ProcessReader(r) +func ProcessStdin(config *k8s.Config, r io.Reader) ([]string, error) { + return yamlparser.ProcessReader(config, r) } -func ProcessFile(filePath string) ([]string, error) { +func ProcessFile(config *k8s.Config, filePath string) ([]string, error) { file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("error reading file: %v", err) } defer file.Close() - return yamlparser.ProcessReader(file) + return yamlparser.ProcessReader(config, file) } diff --git a/processor/processor_test.go b/processor/processor_test.go index 6a77e26..e9fb359 100644 --- a/processor/processor_test.go +++ b/processor/processor_test.go @@ -3,6 +3,8 @@ package processor import ( "os" "testing" + + "github.com/mpv/kir/k8s" ) func TestProcessFile(t *testing.T) { @@ -28,7 +30,7 @@ spec: image: another-image `) - images, err := ProcessFile(dir + "/test.yaml") + images, err := ProcessFile(k8s.DefaultConfig(), dir+"/test.yaml") if err != nil { t.Fatalf("ProcessFile() error = %v", err) } diff --git a/yamlparser/processreader_test.go b/yamlparser/processreader_test.go index 52e58e8..85ca6f3 100644 --- a/yamlparser/processreader_test.go +++ b/yamlparser/processreader_test.go @@ -3,6 +3,8 @@ package yamlparser import ( "strings" "testing" + + "github.com/mpv/kir/k8s" ) // ProcessReader must collect images from every document in a stream and split @@ -51,7 +53,7 @@ func TestProcessReader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ProcessReader(strings.NewReader(tt.data)) + got, err := ProcessReader(k8s.DefaultConfig(), strings.NewReader(tt.data)) if err != nil { t.Fatalf("ProcessReader() error = %v", err) } diff --git a/yamlparser/yamlparser.go b/yamlparser/yamlparser.go index 91a060e..5432f7e 100644 --- a/yamlparser/yamlparser.go +++ b/yamlparser/yamlparser.go @@ -5,21 +5,14 @@ import ( "errors" "fmt" "io" - "slices" "github.com/mpv/kir/k8s" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/serializer" utilyaml "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/yaml" ) -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 +// container images of every workload it contains, using config to decide where each kind keeps its images. Documents are // separated using the Kubernetes YAML reader, which correctly handles leading // and trailing "---" separators, separators followed by trailing whitespace, // CRLF line endings, and a final document without a trailing newline. @@ -30,7 +23,7 @@ var supportedKinds = []string{"Pod", "Deployment", "DaemonSet", "ReplicaSet", "S // image it finds and surface failures through the exit code; that has to hold // within a stream as well as between inputs, or one unparseable document in a // cluster dump costs every image around it. -func ProcessReader(r io.Reader) ([]string, error) { +func ProcessReader(config *k8s.Config, r io.Reader) ([]string, error) { var images []string var errs []error reader := utilyaml.NewYAMLReader(bufio.NewReader(r)) @@ -45,7 +38,7 @@ func ProcessReader(r io.Reader) ([]string, error) { errs = append(errs, fmt.Errorf("error reading YAML document: %v", err)) break } - imgs, err := ProcessData(doc) + imgs, err := ProcessData(config, doc) if err != nil { errs = append(errs, err) continue @@ -55,72 +48,30 @@ func ProcessReader(r io.Reader) ([]string, error) { return images, errors.Join(errs...) } -func ProcessData(data []byte) ([]string, error) { - // Decode the YAML file into a Kubernetes object - decode := serializer.NewCodecFactory(scheme.Scheme).UniversalDeserializer().Decode - obj, gvk, err := decode(data, nil, nil) - if err != nil { - // Kinds that aren't registered in the scheme (CRDs and other custom - // resources) are skipped rather than failing the whole stream. Some of - // them may embed a PodSpec we could inspect; surfacing those ("seen but - // not detected") is tracked in #75. For now they are skipped silently, - // like any other non-workload document — see - // docs/adr/0007-document-classification.md. - if runtime.IsNotRegisteredError(err) { - return nil, nil - } +// ProcessData returns the images in a single manifest document. +// +// The document is decoded into plain Go values rather than into a typed +// Kubernetes object, so a custom resource is reachable on the same terms as a +// built-in: whether it yields images depends only on whether the configuration +// describes it. +func ProcessData(config *k8s.Config, data []byte) ([]string, error) { + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { return nil, err } - var images []string - - // Check if the object has containers - if containers, err := k8s.GetContainersFromObject(obj); err == nil { - images = append(images, k8s.GetContainerImages(containers)...) - return images, nil + // Everything kir accepts is a Kubernetes object, and every Kubernetes + // object has a kind. Requiring it keeps kir pointed at manifests instead of + // mining arbitrary YAML (a Helm values.yaml, say) for anything image-like, + // and it keeps an empty or malformed document an error rather than a silent + // no-op — the unprocessable tier of + // docs/adr/0007-document-classification.md, which ADR 0008 surfaces as a + // non-zero exit. + if _, ok := doc["kind"]; !ok { + return nil, fmt.Errorf("Object 'Kind' is missing in %q", data) } - // Handle List type separately - if gvk.Kind == "List" { - list, ok := obj.(*corev1.List) - if !ok { - return nil, fmt.Errorf("not a List") - } - for _, item := range list.Items { - var unstructuredObj unstructured.Unstructured - if err := unstructuredObj.UnmarshalJSON(item.Raw); err != nil { - return nil, fmt.Errorf("error unmarshaling item: %v", err) - } - imgs, err := processUnstructured(unstructuredObj) - if err != nil { - return nil, fmt.Errorf("error processing unstructured item: %v", err) - } - images = append(images, imgs...) - } - return images, nil - } - - // Any other kind (Service, ConfigMap, ...) is a valid object with no images - // to report, not an error; skip it silently so a single non-workload - // document does not discard images from the rest of the stream. See - // docs/adr/0007-document-classification.md. - return nil, nil -} - -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 + // A document with nothing to report — a Service, a ConfigMap — is not an + // error. See ADR 0007. + return config.FindImages(doc), nil } diff --git a/yamlparser/yamlparser_test.go b/yamlparser/yamlparser_test.go index 53ff24d..099ca6d 100644 --- a/yamlparser/yamlparser_test.go +++ b/yamlparser/yamlparser_test.go @@ -5,6 +5,8 @@ import ( "slices" "strings" "testing" + + "github.com/mpv/kir/k8s" ) func TestProcessData(t *testing.T) { @@ -19,7 +21,7 @@ spec: image: test-image ` - images, err := ProcessData([]byte(data)) + images, err := ProcessData(k8s.DefaultConfig(), []byte(data)) if err != nil { t.Fatalf("ProcessData() error = %v", err) } @@ -55,7 +57,7 @@ spec: targetContainerName: test-container ` - images, err := ProcessData([]byte(data)) + images, err := ProcessData(k8s.DefaultConfig(), []byte(data)) if err != nil { t.Fatalf("ProcessData() error = %v", err) } @@ -83,7 +85,7 @@ func TestProcessReaderKeepsImagesAroundABadDocument(t *testing.T) { "apiVersion: v1\nkind: Pod\nspec:\n containers:\n - {name: c, image: after-the-break}\n", }, "---\n") - images, err := ProcessReader(strings.NewReader(stream)) + images, err := ProcessReader(k8s.DefaultConfig(), strings.NewReader(stream)) if err == nil { t.Error("ProcessReader() error = nil, want the bad document reported") @@ -100,7 +102,7 @@ func TestProcessReaderReportsEveryBadDocument(t *testing.T) { bad := "apiVersion: v1\nkind: Pod\nspec:\n containers:\n - {name: c, image: nginx, ports: [8080}\n" stream := strings.Join([]string{bad, bad}, "---\n") - images, err := ProcessReader(strings.NewReader(stream)) + images, err := ProcessReader(k8s.DefaultConfig(), strings.NewReader(stream)) if len(images) != 0 { t.Errorf("ProcessReader() images = %v, want none", images)