#!/usr/bin/env bash
#
# automate-controllerization-of-naked-pods.sh
#
# Idempotently ensures that non-system, non-controlled Pods are
# recreated via a Deployment named "dp-<podname>" in the same namespace.
#
# REQUIREMENTS:
# - Run on any machine with kubectl, jq, and bash
# - kubeconfig/context pointing at the target GKE cluster
#
# SAFETY:
# - Skips kube-system, kube-public, kube-node-lease
# - Skips Pods that already have an owning controller
# - Skips Pods that are part of a Job/CronJob by label convention
# (job-name / cronjob-name) to avoid breaking Jobs
# - Deletes original naked Pods only after corresponding Deployment
# is created
#
# NOTE:
# - This is a generic automation. You may want to refine label/selector
# logic per application before broad use in production.
set -euo pipefail
# Fail fast if dependencies are missing
for bin in kubectl jq; do
if ! command -v "$bin" >/dev/null 2>&1; then
echo "ERROR: $bin not found in PATH" >&2
exit 1
fi
done
# Optional: namespace allow/deny lists (space-separated). Empty means "all non-system namespaces".
NAMESPACE_INCLUDE="${NAMESPACE_INCLUDE:-}"
NAMESPACE_EXCLUDE="${NAMESPACE_EXCLUDE:-}"
# Label keys we treat as indicating a Job/CronJob pod (common patterns)
JOB_LABEL_KEYS=("job-name" "controller-uid" "batch.kubernetes.io/job-name" "cronjob-name")
# Return 0 (true) if array contains value
array_contains() {
local needle="$1"; shift || true
local x
for x in "$@"; do
[[ "$x" == "$needle" ]] && return 0
done
return 1
}
# Decide whether namespace should be processed
namespace_allowed() {
local ns="$1"
# Skip core system namespaces unconditionally
case "$ns" in
kube-system|kube-public|kube-node-lease)
return 1
;;
esac
# Exclude list
if [[ -n "$NAMESPACE_EXCLUDE" ]]; then
for x in $NAMESPACE_EXCLUDE; do
[[ "$ns" == "$x" ]] && return 1
done
fi
# Include list
if [[ -n "$NAMESPACE_INCLUDE" ]]; then
for x in $NAMESPACE_INCLUDE; do
[[ "$ns" == "$x" ]] && return 0
done
return 1
fi
return 0
}
# Check if pod is controlled (has ownerReferences.controller = true)
is_pod_controlled() {
local ns="$1" name="$2"
local count
count="$(kubectl get pod "$name" -n "$ns" -o json \
| jq '[.metadata.ownerReferences[]? | select(.controller==true)] | length')"
[[ "$count" -gt 0 ]]
}
# Heuristic: check for Job/CronJob related labels
is_job_like_pod() {
local ns="$1" name="$2"
local labels_json
labels_json="$(kubectl get pod "$name" -n "$ns" -o jsonpath='{.metadata.labels}' 2>/dev/null || echo '{}')"
for key in "${JOB_LABEL_KEYS[@]}"; do
if echo "$labels_json" | jq -e --arg k "$key" 'has($k)' >/dev/null 2>&1; then
return 0
fi
done
return 1
}
# Generate a simple Deployment spec from an existing Pod
generate_deployment_from_pod() {
local ns="$1" name="$2" dp_name="$3"
# Fetch pod JSON once
local pod_json
pod_json="$(kubectl get pod "$name" -n "$ns" -o json)"
# Extract container list, labels, and annotations (filtering some well-known pod-only annotations)
local containers labels annotations
containers="$(echo "$pod_json" | jq '.spec.containers')"
labels="$(echo "$pod_json" | jq '.metadata.labels // {}')"
annotations="$(echo "$pod_json" | jq '
.metadata.annotations // {} |
del(
."kubectl.kubernetes.io/last-applied-configuration",
."cni.projectcalico.org/podIP",
."cni.projectcalico.org/podIPs",
."kubernetes.io/config.seen",
."kubernetes.io/config.source"
)')"
# Construct a basic app label if none exist
if [[ "$(echo "$labels" | jq 'length')" -eq 0 ]]; then
labels="$(jq -n --arg app "$dp_name" '{app: $app}')"
fi
# For selector, use same label set (or refine to {app: dp_name})
local selector_labels="$labels"
# Build Deployment manifest
jq -n \
--arg ns "$ns" \
--arg name "$dp_name" \
--argjson containers "$containers" \
--argjson labels "$labels" \
--argjson annotations "$annotations" \
--argjson selector "$selector_labels" '
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name: $name,
namespace: $ns,
labels: $labels,
annotations: $annotations
},
spec: {
replicas: 1,
selector: {
matchLabels: $selector
},
template: {
metadata: {
labels: $labels,
annotations: $annotations
},
spec: {
containers: $containers
}
}
}
}'
}
main() {
echo "=== Discovering naked Pods (no controller owners) in non-system namespaces ==="
# Get all pods except in system namespaces
mapfile -t pods < <(kubectl get pods --all-namespaces -o json \
| jq -r '
.items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| select(([.metadata.ownerReferences[]? | select(.controller==true)] | length) == 0)
| "\(.metadata.namespace) \(.metadata.name)"
')
if [[ ${#pods[@]} -eq 0 ]]; then
echo "No naked Pods found."
fi
for line in "${pods[@]}"; do
ns="$(awk '{print $1}' <<<"$line")"
pod="$(awk '{print $2}' <<<"$line")"
if ! namespace_allowed "$ns"; then
echo "Skipping Pod $ns/$pod (namespace excluded or system)."
continue
fi
# Re-check control in case state changed since discovery
if is_pod_controlled "$ns" "$pod"; then
echo "Skipping Pod $ns/$pod (now has controller owner)."
continue
fi
if is_job_like_pod "$ns" "$pod"; then
echo "Skipping Pod $ns/$pod (looks like Job/CronJob-managed by labels)."
continue
fi
dp_name="dp-${pod}"
# If Deployment already exists, skip creating another and just delete naked Pod
if kubectl get deploy "$dp_name" -n "$ns" >/dev/null 2>&1; then
echo "Deployment $ns/$dp_name already exists; ensuring it manages Pod template and deleting naked Pod $ns/$pod."
kubectl delete pod "$pod" -n "$ns" --wait=false
continue
fi
echo "Creating Deployment $ns/$dp_name from naked Pod $ns/$pod ..."
# Generate manifest to a temp file
tmpfile="$(mktemp)"
if ! generate_deployment_from_pod "$ns" "$pod" "$dp_name" >"$tmpfile"; then
echo "ERROR: Failed to generate Deployment from Pod $ns/$pod; skipping." >&2
rm -f "$tmpfile"
continue
fi
# Apply Deployment
if ! kubectl apply -f "$tmpfile"; then
echo "ERROR: Failed to apply Deployment for Pod $ns/$pod; leaving Pod untouched." >&2
rm -f "$tmpfile"
continue
fi
rm -f "$tmpfile"
# Delete original naked Pod; Deployment will create replacement
kubectl delete pod "$pod" -n "$ns" --wait=false || true
done
echo
echo "=== Verification: re-running compliance audit ==="
kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| (.spec.nodeName // "") as $node
| (($m.labels // {}) | to_entries | map("\(.key):\(.value)") | join(",")) as $labels
| ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
| "kind=Pod ns=\($m.namespace) name=\($m.name) uid=\($m.uid) apiVersion=v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ (if $node == "" then "" else " node=\($node)" end)
+ (if $labels == "" then "" else " labels=\($labels)" end)
+ (if $own == null then "" else " owner=\($own.kind)/\($m.namespace)/\($own.name)/\($own.uid)" end)
+ " is_compliant=\(if $own == null then "false" else "true" end)"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
}
main "$@"