|
#!/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' |