Update deploy-lab.sh

This commit is contained in:
2026-07-18 20:38:55 +00:00
parent 54d5e15b90
commit 77780af646
+733 -52
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Laboratorio estable v4.4.6.1: ZimaOS + Docker + k3d + Argo CD + Multi-Repo + Governance + Monitoring + CI/CD # Laboratorio candidato v4.5.7: ZimaOS + Docker + k3d + Argo CD + Multi-Repo + Governance + Monitoring + Portainer + CI/CD
# #
# Modos: # Modos:
# bootstrap Crea el clúster si no existe; si existe, lo inicia y reconcilia. # bootstrap Crea el clúster si no existe; si existe, lo inicia y reconcilia.
@@ -11,7 +11,10 @@
# monitoring-diagnose Diagnostica kube-prometheus-stack/Grafana. # monitoring-diagnose Diagnostica kube-prometheus-stack/Grafana.
# monitoring-recover Finaliza una operación obsoleta y resincroniza Grafana. # monitoring-recover Finaliza una operación obsoleta y resincroniza Grafana.
# postdeploy-secrets Regenera y valida credenciales de Gitea Actions. # postdeploy-secrets Regenera y valida credenciales de Gitea Actions.
# runner Adopta, crea o recupera el Gitea Actions runner. # portainer Valida o recupera el Portainer Edge Agent.
# portainer-adopt Captura de forma persistente el Edge Agent actual.
# retire-headlamp Elimina residuos de Headlamp cuando ya no existe en Gitea.
# runner Adopta, crea o recupera el Gitea Actions runner.
# install-autostart Instala servicio/timer systemd, orden de montajes y autorreparación. # install-autostart Instala servicio/timer systemd, orden de montajes y autorreparación.
# reset Reconstrucción limpia; requiere confirmación explícita. # reset Reconstrucción limpia; requiere confirmación explícita.
# #
@@ -51,7 +54,25 @@ ARGOCD_VERSION="${ARGOCD_VERSION:-v3.2.0}"
ARGOCD_HOST="${ARGOCD_HOST:-argocd.cruzcloud.net}" ARGOCD_HOST="${ARGOCD_HOST:-argocd.cruzcloud.net}"
EXPECTED_HOST_IP="${EXPECTED_HOST_IP:-192.168.68.61}" EXPECTED_HOST_IP="${EXPECTED_HOST_IP:-192.168.68.61}"
ALLOW_IP_CHANGE="${ALLOW_IP_CHANGE:-false}" ALLOW_IP_CHANGE="${ALLOW_IP_CHANGE:-false}"
INSTALL_HEADLAMP_RBAC="${INSTALL_HEADLAMP_RBAC:-true}" # Portainer Edge Agent para administrar Kubernetes.
INSTALL_PORTAINER_EDGE_AGENT="${INSTALL_PORTAINER_EDGE_AGENT:-true}"
PORTAINER_REQUIRED="${PORTAINER_REQUIRED:-true}"
PORTAINER_NAMESPACE="${PORTAINER_NAMESPACE:-portainer}"
PORTAINER_AGENT_DEPLOYMENT="${PORTAINER_AGENT_DEPLOYMENT:-portainer-agent}"
PORTAINER_AGENT_SERVICE="${PORTAINER_AGENT_SERVICE:-portainer-agent}"
PORTAINER_AGENT_SERVICEACCOUNT="${PORTAINER_AGENT_SERVICEACCOUNT:-portainer-sa-clusteradmin}"
PORTAINER_AGENT_CLUSTERROLEBINDING="${PORTAINER_AGENT_CLUSTERROLEBINDING:-portainer-crb-clusteradmin}"
PORTAINER_AGENT_CONFIGMAP="${PORTAINER_AGENT_CONFIGMAP:-portainer-agent-edge}"
PORTAINER_AGENT_SECRET="${PORTAINER_AGENT_SECRET:-portainer-agent-edge-key}"
PORTAINER_AGENT_IMAGE="${PORTAINER_AGENT_IMAGE:-portainer/agent:2.43.0}"
PORTAINER_SERVER_URL="${PORTAINER_SERVER_URL:-https://192.168.68.61:9444}"
PORTAINER_TUNNEL_HOST="${PORTAINER_TUNNEL_HOST:-192.168.68.61}"
PORTAINER_TUNNEL_PORT="${PORTAINER_TUNNEL_PORT:-8000}"
PORTAINER_EDGE_INSECURE_POLL="${PORTAINER_EDGE_INSECURE_POLL:-1}"
PORTAINER_ROLLOUT_TIMEOUT="${PORTAINER_ROLLOUT_TIMEOUT:-180}"
PORTAINER_BUNDLE_FILE="${PORTAINER_BUNDLE_FILE:-${APP_DATA_PATH}/secrets/portainer-edge-agent.bundle.json}"
PORTAINER_CAPTURE_ON_SUCCESS="${PORTAINER_CAPTURE_ON_SUCCESS:-true}"
REMOVE_LEGACY_HEADLAMP="${REMOVE_LEGACY_HEADLAMP:-true}"
APPLY_POSTDEPLOY_RBAC="${APPLY_POSTDEPLOY_RBAC:-true}" APPLY_POSTDEPLOY_RBAC="${APPLY_POSTDEPLOY_RBAC:-true}"
POSTDEPLOY_SERVICE_ACCOUNT="${POSTDEPLOY_SERVICE_ACCOUNT:-gitea-postdeploy-validator}" POSTDEPLOY_SERVICE_ACCOUNT="${POSTDEPLOY_SERVICE_ACCOUNT:-gitea-postdeploy-validator}"
POSTDEPLOY_TOKEN_SECRET="${POSTDEPLOY_TOKEN_SECRET:-gitea-postdeploy-validator-token}" POSTDEPLOY_TOKEN_SECRET="${POSTDEPLOY_TOKEN_SECRET:-gitea-postdeploy-validator-token}"
@@ -63,6 +84,16 @@ AGENT_RECOVERY_WAIT="${AGENT_RECOVERY_WAIT:-75}"
AUTO_REREGISTER_STALE_AGENTS="${AUTO_REREGISTER_STALE_AGENTS:-true}" AUTO_REREGISTER_STALE_AGENTS="${AUTO_REREGISTER_STALE_AGENTS:-true}"
CHECK_INGRESS_AFTER_BOOT="${CHECK_INGRESS_AFTER_BOOT:-true}" CHECK_INGRESS_AFTER_BOOT="${CHECK_INGRESS_AFTER_BOOT:-true}"
# Arranque conservador basado en la secuencia previamente confirmada:
# Docker restaura k3d y el runner con unless-stopped; systemd solo interviene
# después de una ventana amplia si la API sigue caída.
K3D_AUTOSTART="${K3D_AUTOSTART:-false}"
AUTOSTART_BOOT_GRACE_SECONDS="${AUTOSTART_BOOT_GRACE_SECONDS:-300}"
AUTOSTART_DOCKER_RECOVERY_SECONDS="${AUTOSTART_DOCKER_RECOVERY_SECONDS:-180}"
AUTOSTART_K3D_START_TIMEOUT="${AUTOSTART_K3D_START_TIMEOUT:-360}"
K3D_CONTAINER_RESTART_POLICY="${K3D_CONTAINER_RESTART_POLICY:-unless-stopped}"
GITEA_RUNNER_RESTART_POLICY="${GITEA_RUNNER_RESTART_POLICY:-unless-stopped}"
# Bootstrap GitOps / App-of-Apps. # Bootstrap GitOps / App-of-Apps.
BOOTSTRAP_ROOT_APP="${BOOTSTRAP_ROOT_APP:-true}" BOOTSTRAP_ROOT_APP="${BOOTSTRAP_ROOT_APP:-true}"
ROOT_APP_NAME="${ROOT_APP_NAME:-root-apps-registry}" ROOT_APP_NAME="${ROOT_APP_NAME:-root-apps-registry}"
@@ -172,7 +203,7 @@ sudo_cmd() {
require_commands() { require_commands() {
local command_name local command_name
for command_name in docker k3d kubectl curl git awk sed grep findmnt ss tar base64 tr; do for command_name in docker k3d kubectl curl git awk sed grep findmnt ss tar base64 tr flock; do
have "$command_name" || die "No se encontró el comando requerido: ${command_name}" have "$command_name" || die "No se encontró el comando requerido: ${command_name}"
done done
} }
@@ -241,6 +272,7 @@ prepare_directories() {
"$APP_DATA_PATH" \ "$APP_DATA_PATH" \
"${APP_DATA_PATH}/config" \ "${APP_DATA_PATH}/config" \
"${APP_DATA_PATH}/secrets" \ "${APP_DATA_PATH}/secrets" \
"${APP_DATA_PATH}/run" \
"$BOOTSTRAP_DIR" \ "$BOOTSTRAP_DIR" \
"$BACKUP_DIR" \ "$BACKUP_DIR" \
"$GITEA_RUNNER_ROOT" \ "$GITEA_RUNNER_ROOT" \
@@ -404,7 +436,57 @@ create_cluster() {
start_cluster() { start_cluster() {
log "Iniciando clúster existente ${CLUSTER_NAME}" log "Iniciando clúster existente ${CLUSTER_NAME}"
k3d cluster start "$CLUSTER_NAME" --wait --timeout "${WAIT_SECONDS}s" || true k3d cluster start \
"$CLUSTER_NAME" \
--wait \
--timeout "${AUTOSTART_K3D_START_TIMEOUT}s"
}
cluster_api_ready() {
[[ -s "$KUBECONFIG_PATH" ]] || return 1
KUBECONFIG="$KUBECONFIG_PATH" \
kubectl get --raw='/readyz' \
--request-timeout='5s' >/dev/null 2>&1
}
wait_for_cluster_api_quiet() {
local timeout_seconds="$1"
local deadline=$((SECONDS + timeout_seconds))
while (( SECONDS < deadline )); do
if cluster_api_ready; then
return 0
fi
sleep 5
done
return 1
}
acquire_ensure_lock() {
install -d -m 0750 "${APP_DATA_PATH}/run"
exec 9>"${APP_DATA_PATH}/run/k3d-lab-ensure.lock"
if ! flock -n 9; then
warn "Ya existe otra recuperación del laboratorio en ejecución; se omite esta iteración."
exit 0
fi
}
wait_for_boot_grace() {
[[ "$K3D_AUTOSTART" == "true" ]] || return 0
local uptime_seconds remaining
uptime_seconds="$(
awk '{print int($1)}' /proc/uptime 2>/dev/null || printf '0\n'
)"
if (( uptime_seconds < AUTOSTART_BOOT_GRACE_SECONDS )); then
remaining=$((AUTOSTART_BOOT_GRACE_SECONDS - uptime_seconds))
log "Esperando ${remaining}s para que ZimaOS y Docker restauren las aplicaciones"
sleep "$remaining"
fi
} }
set_restart_policy() { set_restart_policy() {
@@ -413,9 +495,60 @@ set_restart_policy() {
) )
if (( ${#cluster_containers[@]} > 0 )); then if (( ${#cluster_containers[@]} > 0 )); then
docker update --restart=unless-stopped "${cluster_containers[@]}" >/dev/null docker update \
ok "Política Docker unless-stopped aplicada a ${#cluster_containers[@]} contenedores del clúster." --restart="$K3D_CONTAINER_RESTART_POLICY" \
"${cluster_containers[@]}" >/dev/null
ok "Política Docker ${K3D_CONTAINER_RESTART_POLICY} aplicada a ${#cluster_containers[@]} contenedores k3d."
fi fi
if docker inspect "$GITEA_RUNNER_CONTAINER" >/dev/null 2>&1; then
docker update \
--restart="$GITEA_RUNNER_RESTART_POLICY" \
"$GITEA_RUNNER_CONTAINER" >/dev/null
ok "Política Docker ${GITEA_RUNNER_RESTART_POLICY} aplicada a ${GITEA_RUNNER_CONTAINER}."
fi
}
start_runner_if_needed() {
local runner_running
docker inspect "$GITEA_RUNNER_CONTAINER" >/dev/null 2>&1 || return 0
runner_running="$(
docker inspect \
-f '{{.State.Running}}' \
"$GITEA_RUNNER_CONTAINER" 2>/dev/null || true
)"
if [[ "$runner_running" != "true" ]]; then
log "Iniciando ${GITEA_RUNNER_CONTAINER}"
docker start "$GITEA_RUNNER_CONTAINER" >/dev/null
fi
}
recover_cluster_conservatively() {
if cluster_api_ready; then
ok "La API ya está disponible; no se ejecutará k3d cluster start."
return 0
fi
log "La API aún no responde; esperando restauración natural de Docker"
if wait_for_cluster_api_quiet "$AUTOSTART_DOCKER_RECOVERY_SECONDS"; then
ok "La API se recuperó mediante las políticas de Docker."
return 0
fi
warn "La API no se recuperó en ${AUTOSTART_DOCKER_RECOVERY_SECONDS}s; se ejecutará un único k3d cluster start."
if ! start_cluster; then
warn "k3d cluster start devolvió error; se validará igualmente la API porque K3s puede continuar iniciando."
fi
wait_for_cluster_api_quiet "$AUTOSTART_K3D_START_TIMEOUT" ||
die "La API de Kubernetes no se recuperó después del arranque conservador."
} }
write_kubeconfig() { write_kubeconfig() {
@@ -845,28 +978,500 @@ spec:
YAML YAML
} }
install_headlamp_rbac() {
[[ "$INSTALL_HEADLAMP_RBAC" == "true" ]] || return 0
log "Creando RBAC de Headlamp para laboratorio" portainer_fail_or_warn() {
local message="$1"
kubectl create namespace headlamp --dry-run=client -o yaml | kubectl apply -f - if [[ "$PORTAINER_REQUIRED" == "true" ]]; then
kubectl create serviceaccount headlamp-admin -n headlamp --dry-run=client -o yaml | kubectl apply -f - die "$message"
fi
cat <<'YAML' | kubectl apply -f - warn "$message"
apiVersion: rbac.authorization.k8s.io/v1 return 0
kind: ClusterRoleBinding }
metadata:
name: headlamp-admin-binding validate_portainer_server() {
subjects: [[ "$INSTALL_PORTAINER_EDGE_AGENT" == "true" ]] || return 0
- kind: ServiceAccount
name: headlamp-admin log "Validando Portainer Server"
namespace: headlamp
roleRef: if ! curl -ksSf \
apiGroup: rbac.authorization.k8s.io --connect-timeout 5 \
kind: ClusterRole --max-time 12 \
name: cluster-admin -o /dev/null \
YAML "${PORTAINER_SERVER_URL%/}/api/status"; then
portainer_fail_or_warn \
"Portainer API no responde en ${PORTAINER_SERVER_URL%/}/api/status."
return
fi
# El servidor Portainer está en el propio ZimaOS. Validamos que el túnel
# Standard Edge esté escuchando sin abrir sockets desde el contenedor.
if [[ "$PORTAINER_TUNNEL_HOST" == "$LAB_HOST_IP" ||
"$PORTAINER_TUNNEL_HOST" == "$EXPECTED_HOST_IP" ||
"$PORTAINER_TUNNEL_HOST" == "127.0.0.1" ||
"$PORTAINER_TUNNEL_HOST" == "localhost" ]]; then
if ! ss -ltn |
awk -v port=":${PORTAINER_TUNNEL_PORT}" '
$1 == "LISTEN" && $4 ~ port "$" {found=1}
END {exit(found ? 0 : 1)}
'; then
portainer_fail_or_warn \
"Portainer no escucha el túnel Edge en ${PORTAINER_TUNNEL_HOST}:${PORTAINER_TUNNEL_PORT}."
return
fi
fi
ok "Portainer API y túnel Edge disponibles."
}
portainer_resources_exist() {
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" >/dev/null 2>&1 &&
kubectl get configmap "$PORTAINER_AGENT_CONFIGMAP" \
-n "$PORTAINER_NAMESPACE" >/dev/null 2>&1 &&
kubectl get secret "$PORTAINER_AGENT_SECRET" \
-n "$PORTAINER_NAMESPACE" >/dev/null 2>&1
}
capture_portainer_edge_bundle() {
[[ "$INSTALL_PORTAINER_EDGE_AGENT" == "true" ]] || return 0
have python3 ||
die "Se requiere python3 para generar el respaldo declarativo del Portainer Edge Agent."
portainer_resources_exist ||
die "No existe un Portainer Edge Agent completo para adoptar."
local temp_dir output_tmp
temp_dir="$(mktemp -d)"
output_tmp="${PORTAINER_BUNDLE_FILE}.tmp"
kubectl get namespace "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/00-namespace.json"
kubectl get serviceaccount "$PORTAINER_AGENT_SERVICEACCOUNT" \
-n "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/10-serviceaccount.json"
kubectl get clusterrolebinding "$PORTAINER_AGENT_CLUSTERROLEBINDING" \
-o json \
> "${temp_dir}/20-clusterrolebinding.json"
kubectl get service "$PORTAINER_AGENT_SERVICE" \
-n "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/30-service.json"
kubectl get configmap "$PORTAINER_AGENT_CONFIGMAP" \
-n "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/40-configmap.json"
kubectl get secret "$PORTAINER_AGENT_SECRET" \
-n "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/50-secret.json"
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" -o json \
> "${temp_dir}/60-deployment.json"
python3 - "$temp_dir" "$output_tmp" <<'PY'
import json
import pathlib
import sys
source = pathlib.Path(sys.argv[1])
destination = pathlib.Path(sys.argv[2])
items = []
for filename in sorted(source.glob("*.json")):
obj = json.loads(filename.read_text(encoding="utf-8"))
metadata = obj.get("metadata", {})
clean_meta = {
"name": metadata["name"],
}
if metadata.get("namespace"):
clean_meta["namespace"] = metadata["namespace"]
if metadata.get("labels"):
clean_meta["labels"] = metadata["labels"]
annotations = {
key: value
for key, value in metadata.get("annotations", {}).items()
if key not in {
"kubectl.kubernetes.io/last-applied-configuration",
"deployment.kubernetes.io/revision",
"argocd.argoproj.io/tracking-id",
}
}
if annotations:
clean_meta["annotations"] = annotations
clean = {
"apiVersion": obj["apiVersion"],
"kind": obj["kind"],
"metadata": clean_meta,
}
kind = obj["kind"]
if kind == "Namespace":
pass
elif kind == "Secret":
clean["type"] = obj.get("type", "Opaque")
if obj.get("data"):
clean["data"] = obj["data"]
if obj.get("stringData"):
clean["stringData"] = obj["stringData"]
elif kind == "ConfigMap":
if obj.get("data"):
clean["data"] = obj["data"]
if obj.get("binaryData"):
clean["binaryData"] = obj["binaryData"]
else:
spec = obj.get("spec", {})
if kind == "Service":
for key in (
"clusterIP",
"clusterIPs",
"ipFamilies",
"ipFamilyPolicy",
"healthCheckNodePort",
"allocateLoadBalancerNodePorts",
):
spec.pop(key, None)
for port in spec.get("ports", []):
port.pop("nodePort", None)
if kind == "ServiceAccount":
spec.pop("secrets", None)
clean["spec"] = spec
# ClusterRoleBinding no usa spec.
if kind == "ClusterRoleBinding":
clean.pop("spec", None)
clean["roleRef"] = obj["roleRef"]
clean["subjects"] = obj["subjects"]
if kind == "ServiceAccount":
clean.pop("spec", None)
if "automountServiceAccountToken" in obj:
clean["automountServiceAccountToken"] = obj[
"automountServiceAccountToken"
]
if obj.get("imagePullSecrets"):
clean["imagePullSecrets"] = obj["imagePullSecrets"]
items.append(clean)
bundle = {
"apiVersion": "v1",
"kind": "List",
"items": items,
}
destination.write_text(
json.dumps(bundle, indent=2, sort_keys=False) + "\n",
encoding="utf-8",
)
PY
install -m 0600 "$output_tmp" "$PORTAINER_BUNDLE_FILE"
rm -rf "$temp_dir" "$output_tmp"
ok "Portainer Edge Agent adoptado en ${PORTAINER_BUNDLE_FILE}."
warn "El bundle contiene EDGE_KEY; mantenlo con permisos 0600 y fuera de Gitea."
}
restore_portainer_edge_agent() {
[[ -s "$PORTAINER_BUNDLE_FILE" ]] ||
return 1
log "Restaurando Portainer Edge Agent desde el bundle persistente"
kubectl apply -f "$PORTAINER_BUNDLE_FILE"
}
validate_portainer_edge_agent() {
local edge_id insecure actual_image
local secret_keys secret_encoded_bytes
local desired_replicas ready_replicas available_replicas
local pod_count ready_pod_count
local configmap_referenced=false secret_referenced=false
portainer_resources_exist ||
return 1
edge_id="$(
kubectl get configmap "$PORTAINER_AGENT_CONFIGMAP" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.data.EDGE_ID}' 2>/dev/null || true
)"
insecure="$(
kubectl get configmap "$PORTAINER_AGENT_CONFIGMAP" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.data.EDGE_INSECURE_POLL}' 2>/dev/null || true
)"
actual_image="$(
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
2>/dev/null || true
)"
secret_keys="$(
kubectl get secret "$PORTAINER_AGENT_SECRET" \
-n "$PORTAINER_NAMESPACE" \
-o go-template='{{range $key, $value := .data}}{{printf "%s\n" $key}}{{end}}' \
2>/dev/null || true
)"
secret_encoded_bytes="$(
kubectl get secret "$PORTAINER_AGENT_SECRET" \
-n "$PORTAINER_NAMESPACE" \
-o go-template='{{range $key, $value := .data}}{{printf "%d\n" (len $value)}}{{end}}' \
2>/dev/null |
awk '{sum += $1} END {print sum + 0}'
)"
[[ -n "$edge_id" ]] ||
die "ConfigMap/${PORTAINER_AGENT_CONFIGMAP} no contiene EDGE_ID."
[[ -n "$secret_keys" && "${secret_encoded_bytes:-0}" -gt 0 ]] ||
die "Secret/${PORTAINER_AGENT_SECRET} no contiene una credencial Edge válida."
# Portainer puede cambiar entre env, envFrom, secretKeyRef y volúmenes
# según la versión del instalador. La validación no depende de una forma
# concreta; solo registra si los objetos aparecen referenciados.
if kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" -o json |
grep -Fq "\"${PORTAINER_AGENT_CONFIGMAP}\""; then
configmap_referenced=true
fi
if kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" -o json |
grep -Fq "\"${PORTAINER_AGENT_SECRET}\""; then
secret_referenced=true
fi
if [[ "$configmap_referenced" == "true" ]]; then
ok "Deployment referencia ConfigMap/${PORTAINER_AGENT_CONFIGMAP}."
else
warn "No se detectó una referencia textual al ConfigMap; se validará el runtime."
fi
if [[ "$secret_referenced" == "true" ]]; then
ok "Deployment referencia Secret/${PORTAINER_AGENT_SECRET}."
else
warn "No se detectó una referencia textual al Secret; se validará el runtime."
fi
if [[ "$PORTAINER_EDGE_INSECURE_POLL" == "1" && "$insecure" != "1" ]]; then
warn "EDGE_INSECURE_POLL esperado=1; actual=${insecure:-vacío}."
fi
if [[ -n "$actual_image" && "$actual_image" != "$PORTAINER_AGENT_IMAGE" ]]; then
warn "Imagen Portainer Agent actual=${actual_image}; referencia=${PORTAINER_AGENT_IMAGE}."
fi
kubectl rollout status "deployment/${PORTAINER_AGENT_DEPLOYMENT}" \
-n "$PORTAINER_NAMESPACE" \
--timeout="${PORTAINER_ROLLOUT_TIMEOUT}s"
desired_replicas="$(
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.spec.replicas}'
)"
ready_replicas="$(
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.status.readyReplicas}'
)"
available_replicas="$(
kubectl get deployment "$PORTAINER_AGENT_DEPLOYMENT" \
-n "$PORTAINER_NAMESPACE" \
-o jsonpath='{.status.availableReplicas}'
)"
pod_count="$(
kubectl get pods \
-n "$PORTAINER_NAMESPACE" \
-l app=portainer-agent \
--no-headers 2>/dev/null |
wc -l |
tr -d ' '
)"
ready_pod_count="$(
kubectl get pods \
-n "$PORTAINER_NAMESPACE" \
-l app=portainer-agent \
-o go-template='{{range .items}}{{range .status.conditions}}{{if and (eq .type "Ready") (eq .status "True")}}{{printf "1\n"}}{{end}}{{end}}{{end}}' \
2>/dev/null |
wc -l |
tr -d ' '
)"
[[ "${desired_replicas:-0}" -gt 0 ]] ||
die "Deployment/${PORTAINER_AGENT_DEPLOYMENT} tiene réplicas deseadas inválidas."
[[ "${ready_replicas:-0}" -eq "$desired_replicas" ]] ||
die "Portainer Agent Ready=${ready_replicas:-0}; esperado=${desired_replicas}."
[[ "${available_replicas:-0}" -eq "$desired_replicas" ]] ||
die "Portainer Agent Available=${available_replicas:-0}; esperado=${desired_replicas}."
# Algunos manifiestos cambian las etiquetas. El rollout es la fuente
# principal; el conteo por selector se usa como información adicional.
if [[ "${pod_count:-0}" -gt 0 ]]; then
printf 'Pods Portainer detectados : %s\n' "$pod_count"
printf 'Pods Portainer Ready : %s\n' "$ready_pod_count"
fi
ok "Portainer Edge Agent disponible en namespace ${PORTAINER_NAMESPACE}."
}
ensure_portainer_edge_agent() {
[[ "$INSTALL_PORTAINER_EDGE_AGENT" == "true" ]] || return 0
validate_portainer_server
if ! portainer_resources_exist; then
if ! restore_portainer_edge_agent; then
portainer_fail_or_warn \
"No existe Portainer Edge Agent ni bundle persistente. Ejecuta el comando generado por Portainer y luego: $0 portainer-adopt"
return
fi
fi
if ! validate_portainer_edge_agent; then
portainer_fail_or_warn "Portainer Edge Agent no quedó disponible."
return
fi
if [[ "$PORTAINER_CAPTURE_ON_SUCCESS" == "true" &&
! -s "$PORTAINER_BUNDLE_FILE" ]]; then
capture_portainer_edge_bundle
fi
}
portainer_adopt_mode() {
require_commands
ensure_docker
validate_data_mount
validate_host_ip
prepare_directories
cluster_exists ||
die "No existe ${CLUSTER_NAME}."
write_kubeconfig
wait_for_api
validate_portainer_server
validate_portainer_edge_agent
capture_portainer_edge_bundle
}
headlamp_git_references() {
local found=0 path
if [[ -d "${APP_REGISTRY_CACHE_DIR}/workloads/headlamp" ]]; then
printf '%s\n' "${APP_REGISTRY_CACHE_DIR}/workloads/headlamp"
found=1
fi
if [[ -d "$APP_REGISTRY_CACHE_DIR" ]]; then
while IFS= read -r path; do
printf '%s\n' "$path"
found=1
done < <(
grep -RIl \
--exclude-dir=.git \
--include='*.yaml' \
--include='*.yml' \
-E 'name:[[:space:]]*headlamp-app([[:space:]]|$)' \
"$APP_REGISTRY_CACHE_DIR" 2>/dev/null || true
)
fi
if [[ -d "$PLATFORM_INFRA_CACHE_DIR" ]]; then
while IFS= read -r path; do
printf '%s\n' "$path"
found=1
done < <(
grep -RIl \
--exclude-dir=.git \
--include='*.yaml' \
--include='*.yml' \
-E 'name:[[:space:]]*headlamp-governance([[:space:]]|$)' \
"$PLATFORM_INFRA_CACHE_DIR" 2>/dev/null || true
)
while IFS= read -r path; do
printf '%s\n' "$path"
found=1
done < <(
find "$PLATFORM_INFRA_CACHE_DIR" \
-path '*/.git' -prune -o \
-type d -name 'headlamp-governance' -print \
2>/dev/null || true
)
fi
return "$found"
}
retire_headlamp_mode() {
require_commands
ensure_docker
validate_data_mount
validate_host_ip
prepare_directories
cluster_exists ||
die "No existe ${CLUSTER_NAME}."
write_kubeconfig
wait_for_api
sync_gitops_repositories
local references
references="$(headlamp_git_references || true)"
if [[ -n "$references" ]]; then
printf '%s\n' "$references" >&2
die "Headlamp todavía está declarado en Gitea. Elimina esas rutas, haz commit/push y repite retire-headlamp."
fi
log "Retirando residuos de Headlamp"
kubectl delete application \
headlamp-app \
headlamp-governance \
-n argocd \
--ignore-not-found=true \
--wait=false
kubectl delete clusterrolebinding \
headlamp-admin-binding \
--ignore-not-found=true
kubectl delete namespace headlamp \
--ignore-not-found=true \
--wait=false
ok "Headlamp retirado. Portainer queda como consola Kubernetes."
} }
apply_postdeploy_rbac() { apply_postdeploy_rbac() {
@@ -2541,7 +3146,9 @@ print_credentials() {
printf ' NPM Argo CD : http://%s:%s\n' "$LAB_HOST_IP" "$HTTP_PORT" printf ' NPM Argo CD : http://%s:%s\n' "$LAB_HOST_IP" "$HTTP_PORT"
printf ' Host Argo CD : %s\n' "$ARGOCD_HOST" printf ' Host Argo CD : %s\n' "$ARGOCD_HOST"
printf ' ArgoCD admin : %s\n' "$argocd_password" printf ' ArgoCD admin : %s\n' "$argocd_password"
printf ' Token Headlamp : kubectl create token headlamp-admin -n headlamp\n' printf ' Portainer API : %s\n' "$PORTAINER_SERVER_URL"
printf ' Portainer Edge : deployment/%s namespace/%s\n' \
"$PORTAINER_AGENT_DEPLOYMENT" "$PORTAINER_NAMESPACE"
printf ' Kubeconfig : %s\n' "$KUBECONFIG_PATH" printf ' Kubeconfig : %s\n' "$KUBECONFIG_PATH"
printf ' Root Application: %s -> %s/%s@%s\n' \ printf ' Root Application: %s -> %s/%s@%s\n' \
"$ROOT_APP_NAME" "$ROOT_APP_REPO_URL" "$ROOT_APP_REPO_PATH" "$ROOT_APP_REVISION" "$ROOT_APP_NAME" "$ROOT_APP_REPO_URL" "$ROOT_APP_REPO_PATH" "$ROOT_APP_REVISION"
@@ -2596,6 +3203,16 @@ status_report() {
else else
warn "No existe ${GITEA_RUNNER_CONTAINER}." warn "No existe ${GITEA_RUNNER_CONTAINER}."
fi fi
log "Portainer"
printf 'Server: %s\n' "$PORTAINER_SERVER_URL"
printf 'Tunnel: %s:%s\n' "$PORTAINER_TUNNEL_HOST" "$PORTAINER_TUNNEL_PORT"
if cluster_exists && kubectl get namespace "$PORTAINER_NAMESPACE" >/dev/null 2>&1; then
kubectl get deployment,service,configmap,secret -n "$PORTAINER_NAMESPACE" 2>/dev/null || true
else
warn "No existe el namespace ${PORTAINER_NAMESPACE}."
fi
} }
backup_cluster_state() { backup_cluster_state() {
@@ -2646,6 +3263,8 @@ ensure_cluster_only() {
ensure_docker ensure_docker
validate_data_mount validate_data_mount
validate_host_ip validate_host_ip
acquire_ensure_lock
wait_for_boot_grace
if ! cluster_exists; then if ! cluster_exists; then
if cluster_containers_exist; then if cluster_containers_exist; then
@@ -2654,8 +3273,10 @@ ensure_cluster_only() {
die "El clúster ${CLUSTER_NAME} no existe. Ejecuta bootstrap manualmente." die "El clúster ${CLUSTER_NAME} no existe. Ejecuta bootstrap manualmente."
fi fi
start_cluster
set_restart_policy set_restart_policy
start_runner_if_needed
recover_cluster_conservatively
write_kubeconfig write_kubeconfig
wait_for_api wait_for_api
wait_for_nodes wait_for_nodes
@@ -2663,6 +3284,7 @@ ensure_cluster_only() {
ensure_root_application_present ensure_root_application_present
auto_recover_monitoring_if_stale auto_recover_monitoring_if_stale
ensure_gitea_runner ensure_gitea_runner
ensure_portainer_edge_agent
} }
@@ -2726,6 +3348,7 @@ install_autostart() {
local service_user="${SUDO_USER:-devops}" local service_user="${SUDO_USER:-devops}"
local service_home service_group docker_root installed_script local service_home service_group docker_root installed_script
local k3d_path kubectl_path docker_path curl_path tool_path tool_dir local k3d_path kubectl_path docker_path curl_path tool_path tool_dir
local service_result service_status
service_home="$(getent passwd "$service_user" | cut -d: -f6)" service_home="$(getent passwd "$service_user" | cut -d: -f6)"
service_group="$(id -gn "$service_user")" service_group="$(id -gn "$service_user")"
@@ -2804,19 +3427,22 @@ install_autostart() {
install -d -m 0755 \ install -d -m 0755 \
/etc/default \ /etc/default \
/etc/systemd/system \ /etc/systemd/system
/etc/systemd/system/docker.service.d
systemctl enable docker.service >/dev/null systemctl enable docker.service >/dev/null
if systemctl list-unit-files containerd.service >/dev/null 2>&1; then if systemctl list-unit-files containerd.service >/dev/null 2>&1; then
systemctl enable containerd.service >/dev/null || true systemctl enable containerd.service >/dev/null || true
fi fi
cat > /etc/systemd/system/docker.service.d/10-k3d-storage.conf <<UNIT # v4.5.5: el laboratorio nunca debe modificar las dependencias de
[Unit] # docker.service. Un RequiresMountsFor sobre almacenamiento administrado por
RequiresMountsFor=${APP_DATA_PATH} ${docker_root} # ZimaOS puede impedir el arranque global de Docker y, con ello, de todas las
After=local-fs.target # aplicaciones del NAS. Eliminamos el drop-in creado por versiones previas.
UNIT rm -f /etc/systemd/system/docker.service.d/10-k3d-storage.conf
rmdir /etc/systemd/system/docker.service.d 2>/dev/null || true
set_restart_policy
start_runner_if_needed
cat > /etc/default/k3d-lab <<ENV cat > /etc/default/k3d-lab <<ENV
CLUSTER_NAME=${CLUSTER_NAME} CLUSTER_NAME=${CLUSTER_NAME}
@@ -2829,6 +3455,11 @@ ARGOCD_ROLLOUT_TIMEOUT=${ARGOCD_ROLLOUT_TIMEOUT}
AGENT_RECOVERY_WAIT=${AGENT_RECOVERY_WAIT} AGENT_RECOVERY_WAIT=${AGENT_RECOVERY_WAIT}
AUTO_REREGISTER_STALE_AGENTS=${AUTO_REREGISTER_STALE_AGENTS} AUTO_REREGISTER_STALE_AGENTS=${AUTO_REREGISTER_STALE_AGENTS}
CHECK_INGRESS_AFTER_BOOT=${CHECK_INGRESS_AFTER_BOOT} CHECK_INGRESS_AFTER_BOOT=${CHECK_INGRESS_AFTER_BOOT}
AUTOSTART_BOOT_GRACE_SECONDS=${AUTOSTART_BOOT_GRACE_SECONDS}
AUTOSTART_DOCKER_RECOVERY_SECONDS=${AUTOSTART_DOCKER_RECOVERY_SECONDS}
AUTOSTART_K3D_START_TIMEOUT=${AUTOSTART_K3D_START_TIMEOUT}
K3D_CONTAINER_RESTART_POLICY=${K3D_CONTAINER_RESTART_POLICY}
GITEA_RUNNER_RESTART_POLICY=${GITEA_RUNNER_RESTART_POLICY}
K3D_BIN=${k3d_path} K3D_BIN=${k3d_path}
KUBECTL_BIN=${kubectl_path} KUBECTL_BIN=${kubectl_path}
DOCKER_BIN=${docker_path} DOCKER_BIN=${docker_path}
@@ -2858,6 +3489,24 @@ GITEA_RUNNER_DATA_DIR=${GITEA_RUNNER_DATA_DIR}
GITEA_RUNNER_IMAGE_FILE=${GITEA_RUNNER_IMAGE_FILE} GITEA_RUNNER_IMAGE_FILE=${GITEA_RUNNER_IMAGE_FILE}
GITEA_RUNNER_TOKEN_FILE=${GITEA_RUNNER_TOKEN_FILE} GITEA_RUNNER_TOKEN_FILE=${GITEA_RUNNER_TOKEN_FILE}
MIGRATE_EXISTING_RUNNER=false MIGRATE_EXISTING_RUNNER=false
INSTALL_PORTAINER_EDGE_AGENT=${INSTALL_PORTAINER_EDGE_AGENT}
PORTAINER_REQUIRED=${PORTAINER_REQUIRED}
PORTAINER_NAMESPACE=${PORTAINER_NAMESPACE}
PORTAINER_AGENT_DEPLOYMENT=${PORTAINER_AGENT_DEPLOYMENT}
PORTAINER_AGENT_SERVICE=${PORTAINER_AGENT_SERVICE}
PORTAINER_AGENT_SERVICEACCOUNT=${PORTAINER_AGENT_SERVICEACCOUNT}
PORTAINER_AGENT_CLUSTERROLEBINDING=${PORTAINER_AGENT_CLUSTERROLEBINDING}
PORTAINER_AGENT_CONFIGMAP=${PORTAINER_AGENT_CONFIGMAP}
PORTAINER_AGENT_SECRET=${PORTAINER_AGENT_SECRET}
PORTAINER_AGENT_IMAGE=${PORTAINER_AGENT_IMAGE}
PORTAINER_SERVER_URL=${PORTAINER_SERVER_URL}
PORTAINER_TUNNEL_HOST=${PORTAINER_TUNNEL_HOST}
PORTAINER_TUNNEL_PORT=${PORTAINER_TUNNEL_PORT}
PORTAINER_EDGE_INSECURE_POLL=${PORTAINER_EDGE_INSECURE_POLL}
PORTAINER_ROLLOUT_TIMEOUT=${PORTAINER_ROLLOUT_TIMEOUT}
PORTAINER_BUNDLE_FILE=${PORTAINER_BUNDLE_FILE}
PORTAINER_CAPTURE_ON_SUCCESS=${PORTAINER_CAPTURE_ON_SUCCESS}
REMOVE_LEGACY_HEADLAMP=${REMOVE_LEGACY_HEADLAMP}
APP_REGISTRY_CACHE_DIR=${APP_REGISTRY_CACHE_DIR} APP_REGISTRY_CACHE_DIR=${APP_REGISTRY_CACHE_DIR}
APP_REGISTRY_ROOT_MANIFEST=${APP_REGISTRY_ROOT_MANIFEST} APP_REGISTRY_ROOT_MANIFEST=${APP_REGISTRY_ROOT_MANIFEST}
APP_REGISTRY_COMMIT_FILE=${APP_REGISTRY_COMMIT_FILE} APP_REGISTRY_COMMIT_FILE=${APP_REGISTRY_COMMIT_FILE}
@@ -2893,7 +3542,8 @@ Description=Recuperar y validar laboratorio k3d
Wants=network-online.target Wants=network-online.target
After=local-fs.target network-online.target docker.service After=local-fs.target network-online.target docker.service
Requires=docker.service Requires=docker.service
RequiresMountsFor=${APP_DATA_PATH} ${docker_root} # El propio modo ensure valida /DATA y sale de forma segura si todavía no está montado.
# No se agregan dependencias de montaje que puedan bloquear Docker o el arranque global.
StartLimitIntervalSec=15min StartLimitIntervalSec=15min
StartLimitBurst=3 StartLimitBurst=3
@@ -2906,8 +3556,9 @@ Environment=HOME=${service_home}
Environment=KUBECONFIG=${KUBECONFIG_PATH} Environment=KUBECONFIG=${KUBECONFIG_PATH}
Environment="PATH=${tool_path}" Environment="PATH=${tool_path}"
EnvironmentFile=-/etc/default/k3d-lab EnvironmentFile=-/etc/default/k3d-lab
Environment=K3D_AUTOSTART=true
ExecStart=/bin/bash ${installed_script} ensure ExecStart=/bin/bash ${installed_script} ensure
TimeoutStartSec=900 TimeoutStartSec=1500
Restart=no Restart=no
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
@@ -2918,33 +3569,43 @@ UNIT
Description=Watchdog del laboratorio k3d Description=Watchdog del laboratorio k3d
[Timer] [Timer]
OnBootSec=90s # Docker restaura primero k3d y el runner usando unless-stopped. El watchdog
OnUnitInactiveSec=5min # interviene después y solo llama k3d cluster start si la API sigue caída.
OnBootSec=5min
OnUnitInactiveSec=10min
AccuracySec=20s AccuracySec=20s
Persistent=true Persistent=false
Unit=k3d-lab-ensure.service Unit=k3d-lab-ensure.service
[Install] [Install]
WantedBy=timers.target WantedBy=timers.target
UNIT UNIT
systemctl daemon-reload # Evitamos que el timer y la prueba inicial intenten arrancar el
systemctl enable --now k3d-lab-ensure.timer # oneshot al mismo tiempo. También limpiamos contadores históricos de
# StartLimit antes de probar la unidad recién instalada.
systemctl stop k3d-lab-ensure.timer >/dev/null 2>&1 || true
systemctl stop k3d-lab-ensure.service >/dev/null 2>&1 || true
systemctl reset-failed k3d-lab-ensure.service >/dev/null 2>&1 || true
if ! systemctl start k3d-lab-ensure.service; then systemctl daemon-reload
systemctl status k3d-lab-ensure.service --no-pager -l || true systemctl enable --now k3d-lab-ensure.timer >/dev/null
journalctl -u k3d-lab-ensure.service -n 200 --no-pager || true
die "La prueba inicial de k3d-lab-ensure.service falló." systemctl is-active --quiet k3d-lab-ensure.timer ||
fi die "El timer k3d-lab-ensure.timer no quedó activo."
# No ejecutamos inmediatamente el oneshot durante la instalación. Así
# evitamos competir con un clúster que todavía se está recuperando.
ok "Autostart instalado para ${service_user}." ok "Autostart instalado para ${service_user}."
printf 'Script persistente: %s\n' "$installed_script" printf 'Script persistente: %s\n' "$installed_script"
printf 'k3d para systemd: %s\n' "$k3d_path" printf 'k3d para systemd: %s\n' "$k3d_path"
printf 'kubectl para systemd: %s\n' "$kubectl_path" printf 'kubectl para systemd: %s\n' "$kubectl_path"
printf 'PATH para systemd: %s\n' "$tool_path" printf 'PATH para systemd: %s\n' "$tool_path"
printf 'DockerRootDir protegido por systemd: %s\n' "$docker_root" printf 'DockerRootDir detectado (sin modificar docker.service): %s\n' "$docker_root"
printf 'Logs: journalctl -u k3d-lab-ensure.service -n 200 --no-pager\n' printf 'Logs: journalctl -u k3d-lab-ensure.service -n 200 --no-pager\n'
printf 'Nota: el drop-in de Docker será plenamente verificable después del reinicio de prueba.\n' printf 'Seguridad: docker.service no contiene dependencias del laboratorio.\n'
printf 'Orden estable: Docker unless-stopped primero; watchdog después de 5 minutos.\n'
} }
main() { main() {
@@ -3030,6 +3691,26 @@ main() {
validate_postdeploy_credentials validate_postdeploy_credentials
return return
;; ;;
portainer)
require_commands
ensure_docker
validate_data_mount
validate_host_ip
prepare_directories
cluster_exists || die "No existe ${CLUSTER_NAME}."
write_kubeconfig
wait_for_api
ensure_portainer_edge_agent
return
;;
portainer-adopt)
portainer_adopt_mode
return
;;
retire-headlamp)
retire_headlamp_mode
return
;;
esac esac
require_commands require_commands
@@ -3056,7 +3737,7 @@ main() {
reset_cluster reset_cluster
;; ;;
*) *)
die "Modo no válido: ${MODE}. Usa bootstrap, recover, ensure, status, repo-sync, gitops, runner, monitoring-diagnose, monitoring-recover, postdeploy-secrets, install-autostart o reset." die "Modo no válido: ${MODE}. Usa bootstrap, recover, ensure, status, repo-sync, gitops, runner, monitoring-diagnose, monitoring-recover, postdeploy-secrets, portainer, portainer-adopt, retire-headlamp, install-autostart o reset."
;; ;;
esac esac
@@ -3067,7 +3748,6 @@ main() {
install_argocd install_argocd
apply_argocd_ingress apply_argocd_ingress
install_headlamp_rbac
apply_postdeploy_rbac apply_postdeploy_rbac
generate_postdeploy_credentials generate_postdeploy_credentials
sync_gitops_repositories sync_gitops_repositories
@@ -3081,6 +3761,7 @@ main() {
auto_recover_monitoring_if_stale auto_recover_monitoring_if_stale
wait_for_gitops_apps_health wait_for_gitops_apps_health
ensure_gitea_runner ensure_gitea_runner
ensure_portainer_edge_agent
validate_runtime_after_boot validate_runtime_after_boot
log "Validación final" log "Validación final"
@@ -3092,4 +3773,4 @@ main() {
print_credentials print_credentials
} }
main "$@" main "$@"