Skip to content

Instantly share code, notes, and snippets.

@fizz
Created February 27, 2026 18:16
Show Gist options
  • Select an option

  • Save fizz/3b17ead263df7e7db0ec341bd794e58d to your computer and use it in GitHub Desktop.

Select an option

Save fizz/3b17ead263df7e7db0ec341bd794e58d to your computer and use it in GitHub Desktop.
kwhy: explain Kubernetes owner chain, reconcilers, inputs, and revert risk
#!/usr/bin/env bash
set -euo pipefail
# kwhy: explain object lineage, writers, likely reconcilers, inputs, and revert risk.
# MVP: kubectl + jq only, no cluster-side components required.
usage() {
cat <<'EOF'
Usage:
kwhy explain <kind> <name> -n <namespace> [--context <ctx>] [--format text|json]
Examples:
kwhy explain deployment ml-pipeline-ui-artifact -n admin --context mlinfra-prod
kwhy explain deploy ml-pipeline-ui -n kubeflow --format json
EOF
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "error: missing required command: $1" >&2
exit 1
}
}
need_cmd kubectl
need_cmd jq
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
cmd="$1"
shift
if [[ "$cmd" != "explain" ]]; then
usage
exit 1
fi
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
kind="$1"
name="$2"
shift 2
namespace=""
context=""
format="text"
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--namespace)
namespace="$2"
shift 2
;;
--context)
context="$2"
shift 2
;;
--format)
format="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$namespace" ]]; then
echo "error: namespace is required (-n/--namespace)" >&2
exit 1
fi
if [[ "$format" != "text" && "$format" != "json" ]]; then
echo "error: --format must be text or json" >&2
exit 1
fi
kubectl_base=(kubectl)
if [[ -n "$context" ]]; then
kubectl_base+=(--context "$context")
fi
get_json() {
local k="$1"
local n="$2"
local ns="$3"
if [[ -n "$ns" ]]; then
"${kubectl_base[@]}" -n "$ns" get "$k" "$n" -o json 2>/dev/null || true
else
"${kubectl_base[@]}" get "$k" "$n" -o json 2>/dev/null || true
fi
}
obj_json="$(get_json "$kind" "$name" "$namespace")"
if [[ -z "$obj_json" ]]; then
echo "error: unable to fetch target object: $kind/$name in namespace $namespace" >&2
exit 1
fi
obj_kind="$(echo "$obj_json" | jq -r '.kind')"
obj_ns="$(echo "$obj_json" | jq -r '.metadata.namespace // empty')"
obj_uid="$(echo "$obj_json" | jq -r '.metadata.uid')"
owner_chain='[]'
cur_json="$obj_json"
cur_kind="$(echo "$cur_json" | jq -r '.kind')"
cur_name="$(echo "$cur_json" | jq -r '.metadata.name')"
cur_ns="$(echo "$cur_json" | jq -r '.metadata.namespace // empty')"
for _ in $(seq 1 10); do
owner_chain="$(echo "$owner_chain" | jq \
--arg k "$cur_kind" --arg n "$cur_name" --arg ns "$cur_ns" \
'. + [{"kind":$k,"name":$n,"namespace":($ns|select(length>0))}]')"
owner_kind="$(echo "$cur_json" | jq -r '.metadata.ownerReferences[]? | select(.controller==true) | .kind' | head -n1)"
owner_name="$(echo "$cur_json" | jq -r '.metadata.ownerReferences[]? | select(.controller==true) | .name' | head -n1)"
if [[ -z "$owner_kind" || -z "$owner_name" ]]; then
break
fi
next_ns="$cur_ns"
if [[ "$owner_kind" == "Namespace" ]]; then
next_ns=""
fi
next_json="$(get_json "$owner_kind" "$owner_name" "$next_ns")"
if [[ -z "$next_json" ]]; then
owner_chain="$(echo "$owner_chain" | jq \
--arg k "$owner_kind" --arg n "$owner_name" --arg ns "$next_ns" \
'. + [{"kind":$k,"name":$n,"namespace":($ns|select(length>0)),"fetch_error":true}]')"
break
fi
cur_json="$next_json"
cur_kind="$(echo "$cur_json" | jq -r '.kind')"
cur_name="$(echo "$cur_json" | jq -r '.metadata.name')"
cur_ns="$(echo "$cur_json" | jq -r '.metadata.namespace // empty')"
done
write_managers="$(echo "$obj_json" | jq -c '[.metadata.managedFields[]? | {manager,operation,time}]')"
reconciler_hints='[]'
if echo "$obj_json" | jq -e '.metadata.annotations["metacontroller.k8s.io/last-applied-configuration"]? != null' >/dev/null; then
reconciler_hints="$(echo "$reconciler_hints" | jq '. + [{"name":"metacontroller","type":"annotation","confidence":"high","evidence":"metacontroller.k8s.io/last-applied-configuration"}]')"
fi
if echo "$obj_json" | jq -e '.metadata.annotations["argocd.argoproj.io/tracking-id"]? != null' >/dev/null; then
reconciler_hints="$(echo "$reconciler_hints" | jq '. + [{"name":"argocd","type":"annotation","confidence":"high","evidence":"argocd.argoproj.io/tracking-id"}]')"
fi
while IFS= read -r mgr; do
[[ -z "$mgr" || "$mgr" == "null" ]] && continue
lower="$(echo "$mgr" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*metacontroller*)
reconciler_hints="$(echo "$reconciler_hints" | jq --arg m "$mgr" '. + [{"name":"metacontroller","type":"managedFields","confidence":"high","evidence":$m}]')"
;;
*argocd*)
reconciler_hints="$(echo "$reconciler_hints" | jq --arg m "$mgr" '. + [{"name":"argocd","type":"managedFields","confidence":"high","evidence":$m}]')"
;;
*helm*)
reconciler_hints="$(echo "$reconciler_hints" | jq --arg m "$mgr" '. + [{"name":"helm","type":"managedFields","confidence":"medium","evidence":$m}]')"
;;
*kustomize*|*flux*)
reconciler_hints="$(echo "$reconciler_hints" | jq --arg m "$mgr" '. + [{"name":"kustomize/flux","type":"managedFields","confidence":"medium","evidence":$m}]')"
;;
esac
done < <(echo "$write_managers" | jq -r '.[].manager')
reconciler_hints="$(echo "$reconciler_hints" | jq -c 'unique_by(.name,.evidence)')"
inputs="$(echo "$obj_json" | jq -c '
[
(.spec.template.spec.containers[]?.env[]?.valueFrom.configMapKeyRef? | {kind:"ConfigMap",name:.name,key:.key,source:"env.configMapKeyRef"}),
(.spec.template.spec.containers[]?.env[]?.valueFrom.secretKeyRef? | {kind:"Secret",name:.name,key:.key,source:"env.secretKeyRef"}),
(.spec.template.spec.containers[]?.envFrom[]?.configMapRef? | {kind:"ConfigMap",name:.name,source:"envFrom.configMapRef"}),
(.spec.template.spec.containers[]?.envFrom[]?.secretRef? | {kind:"Secret",name:.name,source:"envFrom.secretRef"}),
(.spec.template.spec.volumes[]?.configMap? | {kind:"ConfigMap",name:.name,source:"volumes.configMap"}),
(.spec.template.spec.volumes[]?.secret? | {kind:"Secret",name:.secretName,source:"volumes.secret"})
]
| map(select(.name != null))
| unique_by(.kind,.name,.key,.source)
')"
risk="low"
risk_reason="No strong reconciler signal in annotations/managedFields."
if echo "$reconciler_hints" | jq -e 'length > 0' >/dev/null; then
risk="high"
risk_reason="Detected reconciler hints from annotations/managedFields; manual child edits likely to be overwritten."
fi
if echo "$owner_chain" | jq -e 'any(.kind=="Namespace")' >/dev/null; then
risk="high"
risk_reason="Owner chain includes Namespace parent, common in metacontroller parent-child reconciliation."
fi
sa_name="$(echo "$obj_json" | jq -r '.spec.template.spec.serviceAccountName // empty')"
rbac='[]'
if [[ -n "$sa_name" ]]; then
sa_ns="$namespace"
can_get_cm="$("${kubectl_base[@]}" auth can-i get configmaps -n "$sa_ns" --as="system:serviceaccount:$sa_ns:$sa_name" 2>/dev/null || true)"
can_list_pods="$("${kubectl_base[@]}" auth can-i list pods --all-namespaces --as="system:serviceaccount:$sa_ns:$sa_name" 2>/dev/null || true)"
rbac="$(jq -cn --arg s "$sa_ns/$sa_name" --arg a "$can_get_cm" --arg b "$can_list_pods" '
[
{serviceAccount:$s,check:"get configmaps in namespace",result:$a},
{serviceAccount:$s,check:"list pods cluster-wide",result:$b}
]'
)"
fi
actions="$(jq -cn \
--arg risk "$risk" \
--arg rr "$risk_reason" \
--argjson in "$inputs" '
[
{do:"Follow owner chain to top controller parent",why:"Establish lineage before patching."},
{do:"Inspect managedFields and controller annotations",why:"Find actual reconcilers, not just object owners."},
{do:"Patch controller input (ConfigMap/CR/values), not reconciled child",why:(if $risk=="high" then $rr else "Lower overwrite risk, but source-of-truth patch is still safer." end)},
{do:"Run can-i checks for controller service accounts",why:"Validate RBAC before/after rollout."}
] + (if ($in|length)>0 then [{do:"Review desired-state inputs listed below",why:"These refs commonly drive reconciler output."}] else [] end)
')"
result="$(jq -cn \
--arg kind "$obj_kind" --arg name "$name" --arg ns "$obj_ns" --arg uid "$obj_uid" \
--argjson owners "$owner_chain" \
--argjson managers "$write_managers" \
--argjson recon "$reconciler_hints" \
--argjson ins "$inputs" \
--arg risk "$risk" --arg rr "$risk_reason" \
--argjson rbac "$rbac" \
--argjson acts "$actions" '
{
target:{kind:$kind,name:$name,namespace:$ns,uid:$uid},
owner_chain:$owners,
write_managers:$managers,
likely_reconcilers:$recon,
desired_state_inputs:$ins,
risk:{manual_edit_revert:$risk,reason:$rr},
rbac_sanity:$rbac,
actions:$acts
}
')"
if [[ "$format" == "json" ]]; then
echo "$result" | jq
exit 0
fi
echo "kwhy explain"
echo "Target: $(echo "$result" | jq -r '.target.kind + "/" + .target.name + " ns=" + (.target.namespace // "<cluster-scope>")')"
echo
echo "Owner Chain"
echo "$result" | jq -r '.owner_chain[] | "- " + .kind + "/" + .name + (if .namespace then " (ns=" + .namespace + ")" else "" end) + (if .fetch_error then " [fetch error]" else "" end)'
echo
echo "Write Managers"
if echo "$result" | jq -e '.write_managers | length == 0' >/dev/null; then
echo "- <none>"
else
echo "$result" | jq -r '.write_managers[] | "- manager=" + (.manager // "<none>") + " op=" + (.operation // "<none>") + " time=" + (.time // "<none>")'
fi
echo
echo "Likely Reconcilers"
if echo "$result" | jq -e '.likely_reconcilers | length == 0' >/dev/null; then
echo "- <no strong hints>"
else
echo "$result" | jq -r '.likely_reconcilers[] | "- " + .name + " confidence=" + .confidence + " via " + .type + " (" + .evidence + ")"'
fi
echo
echo "Desired-State Inputs"
if echo "$result" | jq -e '.desired_state_inputs | length == 0' >/dev/null; then
echo "- <none discovered from pod template refs>"
else
echo "$result" | jq -r '.desired_state_inputs[] | "- " + .kind + "/" + .name + (if .key then " key=" + .key else "" end) + " via " + .source'
fi
echo
echo "Revert Risk"
echo "$result" | jq -r '"- manual_edit_revert=" + .risk.manual_edit_revert + " : " + .risk.reason'
echo
echo "RBAC Sanity"
if echo "$result" | jq -e '.rbac_sanity | length == 0' >/dev/null; then
echo "- <service account not inferred from target>"
else
echo "$result" | jq -r '.rbac_sanity[] | "- " + .serviceAccount + " | " + .check + " -> " + .result'
fi
echo
echo "Actions"
echo "$result" | jq -r '.actions[] | "- " + .do + " -- " + .why'

kwhy (MVP)

kwhy explains why a Kubernetes object keeps changing:

  1. Owner chain (lineage)
  2. Write managers (who updated it)
  3. Likely reconcilers (controller hints)
  4. Desired-state inputs (ConfigMaps/Secrets refs)
  5. Manual edit revert risk
  6. Basic RBAC sanity for inferred service account

Why

Owner refs alone are often not enough. A controller can reconcile an object even when owner chain looks normal.

This tool is a fast, local, no-install diagnostic pass for "why did this edit get overwritten?"

Requirements

  • kubectl
  • jq
  • Access to the target cluster/context

Usage

chmod +x scripts/kwhy

# Text output
scripts/kwhy explain deployment ml-pipeline-ui-artifact -n admin --context mlinfra-prod

# JSON output
scripts/kwhy explain deploy ml-pipeline-ui -n kubeflow --context mlinfra-prod --format json

Output Sections

  • Owner Chain: current object up through controller owner refs (best effort)
  • Write Managers: .metadata.managedFields manager/op/time
  • Likely Reconcilers: inferred from annotations + managedFields
  • Desired-State Inputs: pod-template references to ConfigMaps/Secrets
  • Revert Risk: low|high with reason
  • RBAC Sanity: quick kubectl auth can-i checks for inferred service account
  • Actions: recommended next steps

Notes

  • This is heuristic by design. Kubernetes has no universal "single reconciler of record" field.
  • Namespace in owner chain is treated as a strong signal for custom parent-child reconciliation patterns (e.g., metacontroller setups).
  • Use JSON mode when integrating into automation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment