#!/usr/bin/env bash # # export-k8s-manifests.sh # # Ricostruisce i manifest YAML dichiarativi delle risorse installate # su un cluster Kubernetes, per una lista di namespace data in input. # # Rimuove i campi "runtime" (status, managedFields, resourceVersion, uid, # creationTimestamp, ownerReferences generate da controller, ecc.) in modo # da produrre YAML riapplicabili con `kubectl apply -f`. # # Uso: # ./export-k8s-manifests.sh -n ns1,ns2,ns3 [-o output_dir] [-c kubeconfig] [-k context] # ./export-k8s-manifests.sh -f namespaces.txt [-o output_dir] # # Dipendenze: # - kubectl (obbligatorio) # - yq (v4+, https://github.com/mikefarah/yq) — consigliato per pulizia campi # Se assente, viene usato un fallback con python3. # set -euo pipefail # ------------------------------------------------------------------ # Default # ------------------------------------------------------------------ OUTPUT_DIR="./k8s-export-$(date +%Y%m%d-%H%M%S)" NAMESPACES=() NAMESPACE_FILE="" KUBECONFIG_OPT=() CONTEXT_OPT=() INCLUDE_SECRETS=false # Tipi di risorsa namespaced da esportare. # Personalizza questa lista secondo le tue esigenze. RESOURCE_TYPES=( configmap secret service serviceaccount deployment statefulset daemonset replicaset job cronjob ingress networkpolicy poddisruptionbudget horizontalpodautoscaler persistentvolumeclaim role rolebinding # --- API Gateway (namespaced) ------------------------------------- # Gateway API standard (gateway.networking.k8s.io) gateway httproute grpcroute tcproute tlsroute referencegrant # Istio virtualservice destinationrule # Traefik ingressroute middleware # Kong kongingress kongplugin kongconsumer # APISIX apisixroute apisixpluginconfig # NGINX Gateway Fabric — CRD di estensione (gateway.nginx.org) # Le risorse Gateway API standard (gateway, httproute, gatewayclass, ecc.) # sono già coperte sopra; queste sono le estensioni specifiche NGF per # configurazioni non esprimibili nello standard Gateway API. nginxproxy clientsettingspolicy observabilitypolicy upstreamsettingspolicy snippetsfilter # CRD comuni — decommenta/aggiungi secondo gli operator installati cluster.postgresql.cnpg.io # CloudNativePG Cluster scheduledbackup.postgresql.cnpg.io certificate.cert-manager.io ) # Tipi di risorsa CLUSTER-SCOPED da esportare. # Vengono filtrate per pertinenza ai namespace indicati dove possibile # (es. ClusterRoleBinding con subject in uno dei namespace, PV con claim # in uno dei namespace). Le risorse "globali" (StorageClass, GatewayClass, # IngressClass, PriorityClass) vengono esportate sempre, essendo poche # e prive di legame diretto con un namespace. CLUSTER_RESOURCE_TYPES_FILTERED=( clusterrolebinding persistentvolume ) CLUSTER_RESOURCE_TYPES_GLOBAL=( storageclass priorityclass # API Gateway (cluster-scoped) gatewayclass ingressclass # Da valutare se esportare sempre (potenzialmente numerose/rumorose): # clusterrole # customresourcedefinition ) # ------------------------------------------------------------------ # Parsing argomenti # ------------------------------------------------------------------ usage() { cat < Lista namespace separati da virgola -f File con un namespace per riga (alternativo a -n) -o Directory di output (default: ${OUTPUT_DIR}) -c Path al kubeconfig da usare -k Nome del context kubectl da usare -s Includi anche i Secret (default: esclusi per sicurezza) -h Mostra questo help EOF exit 1 } while getopts "n:f:o:c:k:sh" opt; do case "$opt" in n) IFS=',' read -r -a NAMESPACES <<< "$OPTARG" ;; f) NAMESPACE_FILE="$OPTARG" ;; o) OUTPUT_DIR="$OPTARG" ;; c) KUBECONFIG_OPT=(--kubeconfig "$OPTARG") ;; k) CONTEXT_OPT=(--context "$OPTARG") ;; s) INCLUDE_SECRETS=true ;; h) usage ;; *) usage ;; esac done if [[ -n "$NAMESPACE_FILE" ]]; then while IFS= read -r line; do [[ -z "$line" || "$line" =~ ^# ]] && continue NAMESPACES+=("$line") done < "$NAMESPACE_FILE" fi if [[ ${#NAMESPACES[@]} -eq 0 ]]; then echo "Errore: nessun namespace specificato (usa -n o -f)" >&2 usage fi if [[ "$INCLUDE_SECRETS" == false ]]; then RESOURCE_TYPES=("${RESOURCE_TYPES[@]/secret}") fi KCTL=(kubectl "${KUBECONFIG_OPT[@]}" "${CONTEXT_OPT[@]}") # ------------------------------------------------------------------ # Verifica dipendenze # ------------------------------------------------------------------ command -v kubectl >/dev/null 2>&1 || { echo "Errore: kubectl non trovato" >&2; exit 1; } USE_YQ=false if command -v yq >/dev/null 2>&1; then USE_YQ=true elif command -v python3 >/dev/null 2>&1; then USE_YQ=false else echo "Errore: serve 'yq' oppure 'python3' per la pulizia dei manifest" >&2 exit 1 fi echo "Verifica connessione al cluster..." "${KCTL[@]}" cluster-info >/dev/null 2>&1 || { echo "Errore: impossibile contattare il cluster" >&2; exit 1; } CLUSTER_NAME=$("${KCTL[@]}" config current-context 2>/dev/null || echo "unknown-cluster") echo "Cluster context: ${CLUSTER_NAME}" echo "Output dir: ${OUTPUT_DIR}" echo "Namespace da esportare: ${NAMESPACES[*]}" echo "Secret inclusi: ${INCLUDE_SECRETS}" echo mkdir -p "$OUTPUT_DIR" # Gli script Python vengono scritti su file temporanei perché in bash # `python3 - <<'HEREDOC'` sovrascrive lo stdin del processo con l'heredoc, # rendendo impossibile leggere i dati dalla pipeline (il fd 0 è già consumato # prima che il codice Python arrivi a sys.stdin.read()). _PY_CLEAN=$(mktemp) _PY_FILTER=$(mktemp) trap 'rm -f "$_PY_CLEAN" "$_PY_FILTER"' EXIT if [[ "$USE_YQ" == false ]]; then cat > "$_PY_CLEAN" << 'PYCLEAN' import sys, yaml doc = yaml.safe_load(sys.stdin) if doc is None: sys.exit(0) doc.pop("status", None) md = doc.get("metadata", {}) for f in ("uid", "resourceVersion", "generation", "creationTimestamp", "selfLink", "managedFields", "ownerReferences"): md.pop(f, None) ann = md.get("annotations") if ann: ann.pop("kubectl.kubernetes.io/last-applied-configuration", None) ann.pop("deployment.kubernetes.io/revision", None) if not ann: md.pop("annotations", None) spec = doc.get("spec", {}) spec.pop("clusterIP", None) spec.pop("clusterIPs", None) tmpl = spec.get("template", {}) if isinstance(tmpl, dict): tmpl.get("metadata", {}).pop("creationTimestamp", None) print(yaml.dump(doc, sort_keys=False, default_flow_style=False), end="") PYCLEAN cat > "$_PY_FILTER" << 'PYFILTER' import json, os, sys mode = sys.argv[1] data = json.load(sys.stdin) ns_set = set(os.environ.get("NS_CSV", "").split()) for item in data.get("items", []): if mode == "subjects": subs = item.get("subjects") or [] match = any(s.get("namespace") in ns_set for s in subs) else: claim = (item.get("spec") or {}).get("claimRef") or {} match = claim.get("namespace") in ns_set if match: print(item["metadata"]["name"]) PYFILTER fi # ------------------------------------------------------------------ # Funzione di pulizia dei campi runtime # ------------------------------------------------------------------ clean_manifest_yq() { # Rimuove campi generati dal cluster, mantenendo lo spec dichiarativo yq eval ' del(.status) | del(.metadata.uid) | del(.metadata.resourceVersion) | del(.metadata.generation) | del(.metadata.creationTimestamp) | del(.metadata.selfLink) | del(.metadata.managedFields) | del(.metadata.annotations."kubectl.kubernetes.io/last-applied-configuration") | del(.metadata.annotations."deployment.kubernetes.io/revision") | del(.metadata.ownerReferences) | del(.spec.clusterIP) | del(.spec.clusterIPs) | del(.spec.template.metadata.creationTimestamp) ' - } clean_manifest_python() { # Usa il file temporaneo creato all'avvio; l'heredoc inline non funziona # in pipeline perché sovrascrive lo stdin del processo. python3 "$_PY_CLEAN" } clean_manifest() { if [[ "$USE_YQ" == true ]]; then clean_manifest_yq else clean_manifest_python fi } # ------------------------------------------------------------------ # Export per namespace / kind / risorsa # ------------------------------------------------------------------ SUMMARY_FILE="${OUTPUT_DIR}/EXPORT_SUMMARY.md" { echo "# Export manifest Kubernetes" echo echo "- Cluster context: \`${CLUSTER_NAME}\`" echo "- Data export: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "- Namespace: ${NAMESPACES[*]}" echo echo "| Namespace | Kind | Nome | File |" echo "|---|---|---|---|" } > "$SUMMARY_FILE" for ns in "${NAMESPACES[@]}"; do echo "== Namespace: ${ns} ==" if ! "${KCTL[@]}" get namespace "$ns" >/dev/null 2>&1; then echo " ATTENZIONE: namespace '${ns}' non trovato, salto." >&2 continue fi NS_DIR="${OUTPUT_DIR}/${ns}" mkdir -p "$NS_DIR" for kind in "${RESOURCE_TYPES[@]}"; do [[ -z "$kind" ]] && continue # Il controllo api-resources è omesso: kubectl get 2>/dev/null || true # gestisce già i resource type non esistenti restituendo NAMES vuoto. NAMES=$("${KCTL[@]}" -n "$ns" get "$kind" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) [[ -z "$NAMES" ]] && continue KIND_DIR="${NS_DIR}/${kind}" mkdir -p "$KIND_DIR" while IFS= read -r name; do [[ -z "$name" ]] && continue # Salta le risorse generate automaticamente (es. default token secret, # ReplicaSet gestiti da Deployment, Pod gestiti da controller superiori) if [[ "$kind" == "secret" && "$name" =~ ^default-token- ]]; then continue fi OUT_FILE="${KIND_DIR}/${name}.yaml" echo " -> ${kind}/${name}" if "${KCTL[@]}" -n "$ns" get "$kind" "$name" -o yaml 2>/dev/null | clean_manifest > "$OUT_FILE"; then echo "| ${ns} | ${kind} | ${name} | \`${ns}/${kind}/${name}.yaml\` |" >> "$SUMMARY_FILE" else echo " ATTENZIONE: export fallito per ${kind}/${name}" >&2 rm -f "$OUT_FILE" fi done <<< "$NAMES" done done # ------------------------------------------------------------------ # Export risorse cluster-scoped # ------------------------------------------------------------------ echo echo "== Risorse cluster-scoped ==" CLUSTER_DIR="${OUTPUT_DIR}/_cluster-scoped" # Cache unica delle risorse disponibili (evita N chiamate a api-resources) _AVAIL_NAMES=$("${KCTL[@]}" api-resources --no-headers 2>/dev/null | awk '{print tolower($1)}' || true) _AVAIL_KINDS=$("${KCTL[@]}" api-resources --no-headers 2>/dev/null | awk '{print tolower($NF)}' || true) resource_type_exists() { local kind="$1" # Controlla sia la colonna NAME (plurale) sia la colonna KIND (singolare) del # risultato di api-resources per gestire plurali irregolari (es. ingress→ingresses). echo "$_AVAIL_NAMES" | grep -qx "${kind}s" \ || echo "$_AVAIL_KINDS" | grep -qx "$kind" } export_cluster_resource() { local kind="$1" local name="$2" local kind_dir="${CLUSTER_DIR}/${kind}" mkdir -p "$kind_dir" local out_file="${kind_dir}/${name}.yaml" echo " -> ${kind}/${name}" if "${KCTL[@]}" get "$kind" "$name" -o yaml 2>/dev/null | clean_manifest > "$out_file"; then echo "| (cluster) | ${kind} | ${name} | \`_cluster-scoped/${kind}/${name}.yaml\` |" >> "$SUMMARY_FILE" else echo " ATTENZIONE: export fallito per ${kind}/${name}" >&2 rm -f "$out_file" fi } # --- Risorse globali: esportate sempre, senza filtro namespace --- for kind in "${CLUSTER_RESOURCE_TYPES_GLOBAL[@]}"; do [[ -z "$kind" ]] && continue resource_type_exists "$kind" || continue NAMES=$("${KCTL[@]}" get "$kind" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) [[ -z "$NAMES" ]] && continue while IFS= read -r name; do [[ -z "$name" ]] && continue export_cluster_resource "$kind" "$name" done <<< "$NAMES" done # Filtra gli item di un array JSON kubectl (-o json) tenendo solo quelli # per cui `jq_path` (letto tramite kubectl jsonpath-like con yq/python) ha # un valore presente nella lista NAMESPACES. Stampa i nomi corrispondenti. # $1 = json completo (stdin) $2 = "subjects" | "claimref" filter_by_namespace_field() { local mode="$1" if [[ "$USE_YQ" == true ]]; then if [[ "$mode" == "subjects" ]]; then yq eval '.items[] | select((.subjects // []) | any_c(.namespace == env(NS_MATCH))) | .metadata.name' - else yq eval '.items[] | select(.spec.claimRef.namespace == env(NS_MATCH)) | .metadata.name' - fi else python3 "$_PY_FILTER" "$mode" fi } # --- ClusterRoleBinding: solo quelli con subject in uno dei namespace indicati --- if resource_type_exists clusterrolebinding; then MATCHED_CRB="" for ns in "${NAMESPACES[@]}"; do RESULT=$("${KCTL[@]}" get clusterrolebinding -o json 2>/dev/null \ | NS_CSV="${NAMESPACES[*]}" NS_MATCH="$ns" filter_by_namespace_field subjects || true) MATCHED_CRB="${MATCHED_CRB} ${RESULT}" done MATCHED_CRB=$(echo "$MATCHED_CRB" | sort -u | sed '/^$/d') while IFS= read -r name; do [[ -z "$name" ]] && continue export_cluster_resource clusterrolebinding "$name" done <<< "$MATCHED_CRB" fi # --- PersistentVolume: solo quelli con claimRef in uno dei namespace indicati --- if resource_type_exists persistentvolume; then MATCHED_PV="" for ns in "${NAMESPACES[@]}"; do RESULT=$("${KCTL[@]}" get persistentvolume -o json 2>/dev/null \ | NS_CSV="${NAMESPACES[*]}" NS_MATCH="$ns" filter_by_namespace_field claimref || true) MATCHED_PV="${MATCHED_PV} ${RESULT}" done MATCHED_PV=$(echo "$MATCHED_PV" | sort -u | sed '/^$/d') while IFS= read -r name; do [[ -z "$name" ]] && continue export_cluster_resource persistentvolume "$name" done <<< "$MATCHED_PV" fi echo echo "Export completato in: ${OUTPUT_DIR}" echo "Riepilogo: ${SUMMARY_FILE}"