diff --git a/deploy-lab.sh b/deploy-lab.sh index d6f80de..b4019cd 100644 --- a/deploy-lab.sh +++ b/deploy-lab.sh @@ -1,5 +1,5 @@ #!/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: # 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-recover Finaliza una operación obsoleta y resincroniza Grafana. # 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. # 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}" EXPECTED_HOST_IP="${EXPECTED_HOST_IP:-192.168.68.61}" 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}" POSTDEPLOY_SERVICE_ACCOUNT="${POSTDEPLOY_SERVICE_ACCOUNT:-gitea-postdeploy-validator}" 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}" 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_ROOT_APP="${BOOTSTRAP_ROOT_APP:-true}" ROOT_APP_NAME="${ROOT_APP_NAME:-root-apps-registry}" @@ -172,7 +203,7 @@ sudo_cmd() { require_commands() { 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}" done } @@ -241,6 +272,7 @@ prepare_directories() { "$APP_DATA_PATH" \ "${APP_DATA_PATH}/config" \ "${APP_DATA_PATH}/secrets" \ + "${APP_DATA_PATH}/run" \ "$BOOTSTRAP_DIR" \ "$BACKUP_DIR" \ "$GITEA_RUNNER_ROOT" \ @@ -404,7 +436,57 @@ create_cluster() { start_cluster() { 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() { @@ -413,9 +495,60 @@ set_restart_policy() { ) if (( ${#cluster_containers[@]} > 0 )); then - docker update --restart=unless-stopped "${cluster_containers[@]}" >/dev/null - ok "Política Docker unless-stopped aplicada a ${#cluster_containers[@]} contenedores del clúster." + docker update \ + --restart="$K3D_CONTAINER_RESTART_POLICY" \ + "${cluster_containers[@]}" >/dev/null + + ok "Política Docker ${K3D_CONTAINER_RESTART_POLICY} aplicada a ${#cluster_containers[@]} contenedores k3d." 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() { @@ -845,28 +978,500 @@ spec: 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 - - kubectl create serviceaccount headlamp-admin -n headlamp --dry-run=client -o yaml | kubectl apply -f - + if [[ "$PORTAINER_REQUIRED" == "true" ]]; then + die "$message" + fi - cat <<'YAML' | kubectl apply -f - -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: headlamp-admin-binding -subjects: - - kind: ServiceAccount - name: headlamp-admin - namespace: headlamp -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -YAML + warn "$message" + return 0 +} + +validate_portainer_server() { + [[ "$INSTALL_PORTAINER_EDGE_AGENT" == "true" ]] || return 0 + + log "Validando Portainer Server" + + if ! curl -ksSf \ + --connect-timeout 5 \ + --max-time 12 \ + -o /dev/null \ + "${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() { @@ -2541,7 +3146,9 @@ print_credentials() { printf ' NPM Argo CD : http://%s:%s\n' "$LAB_HOST_IP" "$HTTP_PORT" printf ' Host Argo CD : %s\n' "$ARGOCD_HOST" 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 ' Root Application: %s -> %s/%s@%s\n' \ "$ROOT_APP_NAME" "$ROOT_APP_REPO_URL" "$ROOT_APP_REPO_PATH" "$ROOT_APP_REVISION" @@ -2596,6 +3203,16 @@ status_report() { else warn "No existe ${GITEA_RUNNER_CONTAINER}." 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() { @@ -2646,6 +3263,8 @@ ensure_cluster_only() { ensure_docker validate_data_mount validate_host_ip + acquire_ensure_lock + wait_for_boot_grace if ! cluster_exists; then if cluster_containers_exist; then @@ -2654,8 +3273,10 @@ ensure_cluster_only() { die "El clúster ${CLUSTER_NAME} no existe. Ejecuta bootstrap manualmente." fi - start_cluster set_restart_policy + start_runner_if_needed + recover_cluster_conservatively + write_kubeconfig wait_for_api wait_for_nodes @@ -2663,6 +3284,7 @@ ensure_cluster_only() { ensure_root_application_present auto_recover_monitoring_if_stale ensure_gitea_runner + ensure_portainer_edge_agent } @@ -2726,6 +3348,7 @@ install_autostart() { local service_user="${SUDO_USER:-devops}" local service_home service_group docker_root installed_script 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_group="$(id -gn "$service_user")" @@ -2804,19 +3427,22 @@ install_autostart() { install -d -m 0755 \ /etc/default \ - /etc/systemd/system \ - /etc/systemd/system/docker.service.d + /etc/systemd/system systemctl enable docker.service >/dev/null if systemctl list-unit-files containerd.service >/dev/null 2>&1; then systemctl enable containerd.service >/dev/null || true fi - cat > /etc/systemd/system/docker.service.d/10-k3d-storage.conf </dev/null || true + + set_restart_policy + start_runner_if_needed cat > /etc/default/k3d-lab </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 status k3d-lab-ensure.service --no-pager -l || true - journalctl -u k3d-lab-ensure.service -n 200 --no-pager || true - die "La prueba inicial de k3d-lab-ensure.service falló." - fi + systemctl daemon-reload + systemctl enable --now k3d-lab-ensure.timer >/dev/null + + systemctl is-active --quiet k3d-lab-ensure.timer || + 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}." printf 'Script persistente: %s\n' "$installed_script" printf 'k3d para systemd: %s\n' "$k3d_path" printf 'kubectl para systemd: %s\n' "$kubectl_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 '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() { @@ -3030,6 +3691,26 @@ main() { validate_postdeploy_credentials 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 require_commands @@ -3056,7 +3737,7 @@ main() { 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 @@ -3067,7 +3748,6 @@ main() { install_argocd apply_argocd_ingress - install_headlamp_rbac apply_postdeploy_rbac generate_postdeploy_credentials sync_gitops_repositories @@ -3081,6 +3761,7 @@ main() { auto_recover_monitoring_if_stale wait_for_gitops_apps_health ensure_gitea_runner + ensure_portainer_edge_agent validate_runtime_after_boot log "Validación final" @@ -3092,4 +3773,4 @@ main() { print_credentials } -main "$@" +main "$@" \ No newline at end of file