Skip to content
Merged
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
85 changes: 85 additions & 0 deletions cmd/k8s.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"encoding/json"
"os"

"github.com/spf13/cobra"

"github.com/raphgm/container-preflight/internal/executor"
"github.com/raphgm/container-preflight/pkg/preflight"
"github.com/raphgm/container-preflight/pkg/preflight/k8s"
)

var (
k8sContext string
k8sCluster string
k8sSaveTo string
k8sOffline bool
k8sFormat string
)

var k8sCmd = &cobra.Command{
Use: "k8s [manifests...]",
Short: "Predict what applying Kubernetes manifests to a cluster would fail on",
Long: `Reads manifests (files, directories, or - for stdin, e.g. from
` + "`helm template`" + ` or ` + "`kustomize build`" + `), profiles the target cluster with
read-only kubectl calls, and queries registries for image platforms. It
predicts FailedScheduling, ImagePullBackOff, "no matches for kind", Pending
volume claims and wrong-architecture images before anything is applied.

container-preflight k8s ./deploy
helm template my-release ./chart | container-preflight k8s -
container-preflight k8s --save-cluster prod.json # capture a cluster
container-preflight k8s ./deploy --cluster prod.json # check against it`,
RunE: func(cmd *cobra.Command, args []string) error {
if k8sSaveTo != "" {
snap := k8s.Capture(cmd.Context(), executor.New(), k8sContext)
f, err := os.Create(k8sSaveTo)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(snap)
}
if len(args) == 0 {
args = []string{"."}
}
opts := preflight.K8sOptions{Paths: args, Context: k8sContext, Offline: k8sOffline}
if k8sCluster != "" {
snap, err := k8s.LoadSnapshot(k8sCluster)
if err != nil {
return err
}
opts.Snapshot = snap
}
rep, err := preflight.RunK8s(cmd.Context(), opts)
if err != nil {
return err
}
if k8sFormat == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
return err
}
} else {
renderPreflight(os.Stdout, rep, useColor())
}
if rep.Errors() > 0 {
os.Exit(1)
}
return nil
},
}

func init() {
rootCmd.AddCommand(k8sCmd)
k8sCmd.Flags().StringVar(&k8sContext, "context", "", "kubeconfig context (default: current)")
k8sCmd.Flags().StringVar(&k8sCluster, "cluster", "", "Check against a saved cluster snapshot instead of a live cluster")
k8sCmd.Flags().StringVar(&k8sSaveTo, "save-cluster", "", "Save a snapshot of the cluster to this file and exit")
k8sCmd.Flags().BoolVar(&k8sOffline, "offline", false, "Skip registry lookups")
k8sCmd.Flags().StringVarP(&k8sFormat, "format", "f", "term", "Output format (term, json)")
}
6 changes: 6 additions & 0 deletions cmd/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ func renderPreflight(w io.Writer, rep *preflight.Report, color bool) {
}

h := rep.Host
if h == nil {
h = &host.Profile{}
}
fmt.Fprintf(w, "\n%sContainer Preflight · preflight%s %s%s%s\n", c.bold, c.reset, c.dim, rep.Dir, c.reset)
if rep.HostName != "" {
fmt.Fprintf(w, "%sPredicting for snapshot of %s%s\n", c.yellow, rep.HostName, c.reset)
Expand All @@ -115,6 +118,9 @@ func renderPreflight(w io.Writer, rep *preflight.Report, color bool) {
if h.BuildxInstalled {
hostBits = append(hostBits, "buildx "+h.BuildxVersion)
}
if c := rep.Cluster; c != nil && c.ServerVersion != "" {
hostBits = append(hostBits, "cluster "+c.Context, "Kubernetes "+c.ServerVersion, fmt.Sprintf("%d node(s): %s", len(c.Nodes), strings.Join(c.Platforms(), ", ")))
}
if len(hostBits) > 0 {
fmt.Fprintf(w, "%sHost: %s%s\n", c.dim, strings.Join(hostBits, " · "), c.reset)
}
Expand Down
45 changes: 45 additions & 0 deletions docs/validation/2026-09-24-minikube-k8s.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Validation run: Kubernetes rules on minikube

Date: 2026-09-24
Cluster: minikube (Docker driver on Docker Desktop, Apple silicon), Kubernetes v1.37.0, one
`linux/arm64` node, default StorageClass `standard`.
Manifests: `examples/k8s/app.yaml`.

`container-preflight k8s examples/k8s` was run first. The manifests were then applied to a
throw-away namespace, and the events Kubernetes reported were compared with the predictions.

| Object | Prediction | Real outcome | Verdict |
|---|---|---|---|
| `Ingress/old-ingress` | `no matches for kind "Ingress" in version "extensions/v1beta1"` | `kubectl apply`: identical | ✅ exact |
| `Pod/typo` (`nginx:9.99.99`) | `ErrImagePull` | `ErrImagePull`, then `ImagePullBackOff` (`failed to resolve reference … not found`) | ✅ |
| `Deployment/huge` (64 GiB request) | `FailedScheduling: 0/1 nodes are available: 1 Insufficient memory.` | identical, followed by a preemption note | ✅ exact |
| `StatefulSet/db` (StorageClass `gp3`) | PVC Pending: `storageclass.storage.k8s.io "gp3" not found` | `ProvisioningFailed` with that exact text; PVC `Pending`; pod `FailedScheduling: … unbound immediate PersistentVolumeClaims` | ✅ exact |
| `Deployment/web` (`pause-amd64`, amd64 only) | first version: cannot run on any node → `exec format error` | pods `Running` | ❌ false positive |

The false positive has the same cause as the Colima case. minikube's node runs inside Docker
Desktop's VM, which runs amd64 binaries under Rosetta, so the node inherits emulation that the
cluster API does not report. Local clusters (minikube, kind, Docker Desktop, k3d, Rancher
Desktop, OrbStack, Colima) now get a warning: "will only run under emulation". Other clusters keep
the error, because production arm64 nodes normally have no emulation. The Ingress fix also names
the served version (`networking.k8s.io/v1`) instead of suggesting a CRD.

Result: 4 of 5 predictions confirmed, 3 word for word. After the fix the tool's output matches
what the cluster did.

## Second round: scheduling reasons, snapshots, root cause

| Pod | Predicted | Real `FailedScheduling` event |
|---|---|---|
| `wrong-pool` (`nodeSelector: pool=gpu`) | `0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector.` | identical ✅ |
| `wants-gpu` (`nvidia.com/gpu: 1`) | `0/1 nodes are available: 1 Insufficient nvidia.com/gpu.` | identical ✅ |
| `arm-only-affinity` (required affinity `arch In [amd64]`) | `0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector.` | identical ✅ |

- **Snapshot.** `--save-cluster` wrote an 8 KB profile. Checking against it with
`--cluster`, and no cluster access, produced the same three predictions.
- **Unreachable cluster.** A context pointing at an address that does not answer was reported
once, as "Kubernetes API server is not reachable (context ghost)", blocking `k8s.api`,
`k8s.platform`, `k8s.schedulable` and `k8s.storage`. `k8s.image` still ran because it needs
only the registry.

Combined result on minikube: 7 of 8 predictions confirmed word for word. The remaining one is
the emulation case, fixed above.
4 changes: 4 additions & 0 deletions examples/k8s-chart/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v2
name: demo
description: Small chart with deliberate problems, for trying `container-preflight k8s -`.
version: 0.1.0
20 changes: 20 additions & 0 deletions examples/k8s-chart/templates/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
apiVersion: apps/v1
kind: Deployment
metadata: {name: {{ .Release.Name }}-web}
spec:
selector: {matchLabels: {app: web}}
template:
metadata: {labels: {app: web}}
spec:
containers:
- name: web
image: {{ .Values.image }}
resources: {requests: {memory: {{ .Values.memory }}}}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: {{ .Release.Name }}-data}
spec:
storageClassName: {{ .Values.storageClass }}
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}
3 changes: 3 additions & 0 deletions examples/k8s-chart/values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
image: nginx:9.99.99 # tag that does not exist
memory: 64Gi # more than a laptop cluster has
storageClass: gp3 # AWS class, absent on local clusters
56 changes: 56 additions & 0 deletions examples/k8s/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Deliberately broken manifests for trying `container-preflight k8s`.
apiVersion: apps/v1
kind: Deployment
metadata: {name: web}
spec:
replicas: 2
selector: {matchLabels: {app: web}}
template:
metadata: {labels: {app: web}}
spec:
containers:
- name: web
image: gcr.io/google-containers/pause-amd64:3.2 # amd64 only
resources: {requests: {cpu: 100m, memory: 64Mi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: huge}
spec:
selector: {matchLabels: {app: huge}}
template:
metadata: {labels: {app: huge}}
spec:
containers:
- name: huge
image: busybox:1.36
command: ["sleep", "3600"]
resources: {requests: {memory: 64Gi}}
---
apiVersion: v1
kind: Pod
metadata: {name: typo}
spec:
containers: [{name: x, image: nginx:9.99.99}]
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata: {name: old-ingress}
spec: {}
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: db}
spec:
serviceName: db
selector: {matchLabels: {app: db}}
template:
metadata: {labels: {app: db}}
spec:
containers: [{name: db, image: busybox:1.36, command: ["sleep", "3600"]}]
volumeClaimTemplates:
- metadata: {name: data}
spec:
storageClassName: gp3
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}
12 changes: 12 additions & 0 deletions pkg/preflight/fact/fact.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const (
Registry ID = "registry.network"
ComposeFile ID = "project.compose"
Dockerfile ID = "project.dockerfile"

Kubectl ID = "k8s.kubectl"
Cluster ID = "k8s.cluster"
ClusterAPIs ID = "k8s.apis"
Nodes ID = "k8s.nodes"
Storage ID = "k8s.storage"
Manifests ID = "project.manifests"
)

// parents encodes the causal edges: a fact cannot be known unless its parent
Expand All @@ -28,6 +35,11 @@ var parents = map[ID]ID{
LocalDaemon: DockerCLI,
Compose: DockerCLI,
Buildx: DockerCLI,

Cluster: Kubectl,
ClusterAPIs: Cluster,
Nodes: Cluster,
Storage: Cluster,
}

// Parent returns the direct causal parent of id, if any.
Expand Down
Loading
Loading