#!/usr/bin/env bash # ============================================================================= # deploy.sh — Deploy di un nuovo tenant LoginMaster su Kubernetes. # # Embeddato (hardcoded): # - database LoginMaster-Tenant # - replica set name rs0 # - replicas 3 mongo / 2 api / 1 admin # - resources, probe, PDB, anti-affinity, TLS issuer letsencrypt-prod # - registry hub.codebaker.it/loginmaster-tenant/ # # Chiesto a runtime: # - namespace (default: loginmaster-tenant) # - context kubectl, storage class/size # - tag immagini # - domini pubblici (api + admin) # - LOGINMASTER_API_URL, ADMIN_ALLOWED_ORIGIN # - SMTP config (opzionale) # - credenziali registry codebaker # # Generato automaticamente: # - MASTER_ENCRYPTION_KEY, mongo root pwd, mongo app pwd, mongo keyfile # (salvate in .deploy-credentials.txt con chmod 600) # # Idempotente: rilanciabile in sicurezza. # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEMPLATES_DIR="$SCRIPT_DIR/templates" CREDS_FILE="$SCRIPT_DIR/.deploy-credentials.txt" # ----------------------------------------------------------------------------- # Costanti (embedded) # ----------------------------------------------------------------------------- DB_NAME="LoginMaster-Tenant" RS_NAME="rs0" REGISTRY_SERVER="hub.codebaker.it" SVC_HEADLESS="mongodb-tenant-headless" POD0="mongodb-tenant-0" POD1="mongodb-tenant-1" POD2="mongodb-tenant-2" # Default per i prompt DEF_NAMESPACE="loginmaster-tenant" DEF_IMAGE_TAG_API="prod-1.3.8" DEF_IMAGE_TAG_ADMIN="prod-1.3.7" DEF_DOMAIN_API="api.tenant.example.com" DEF_DOMAIN_ADMIN="admin.tenant.example.com" DEF_LOGINMASTER_API_URL="https://api.loginmaster.it/1.3/" DEF_ADMIN_ALLOWED_ORIGIN="https://admin.loginmaster.it" DEF_STORAGE_SIZE="20Gi" # Mongo: il limite memoria deve coprire la WiredTiger cache (default ~50% RAM) # + working set + connessioni. 1Gi è insufficiente per qualsiasi carico reale. DEF_MONGO_CPU_REQUEST="250m" DEF_MONGO_CPU_LIMIT="2" DEF_MONGO_MEM_REQUEST="1Gi" DEF_MONGO_MEM_LIMIT="2Gi" # DEF_REGISTRY_USERNAME volutamente vuoto: ogni cliente ha le sue credenziali # di accesso al registry, non c'è un default sensato. DEF_REGISTRY_USERNAME="" DEF_ROOT_USER="admin" DEF_APP_USER="lmtenant" # ----------------------------------------------------------------------------- # Helper # ----------------------------------------------------------------------------- BLUE='\033[0;34m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m' log() { echo -e "\n${BLUE}==>${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}!${NC} $*"; } err() { echo -e "${RED}ERRORE:${NC} $*" >&2; exit 1; } ask() { # ask "Prompt" "default" -> popola REPLY local prompt="$1" def="${2:-}" input if [[ -n "$def" ]]; then read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${def}]: ")" input REPLY="${input:-$def}" else read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" input REPLY="$input" fi } ask_secret() { local prompt="$1" input read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" input echo REPLY="$input" } confirm() { local prompt="$1" def="${2:-N}" input suffix [[ "$def" == "Y" ]] && suffix="[Y/n]" || suffix="[y/N]" read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} ${suffix}: ")" input input="${input:-$def}" [[ "$input" =~ ^[yY]([eE][sS])?$ ]] } render() { # Sostituisce ${VAR} nel template con le variabili esportate, via envsubst. # Il fallback sed è stato rimosso perché non gestiva correttamente caratteri # speciali (|, &, \, newline) in valori realistici come EMAIL_FROM. envsubst < "$1" } kc() { kubectl --context "$CONTEXT" -n "$NAMESPACE" "$@"; } kc_cluster() { kubectl --context "$CONTEXT" "$@"; } # mongosh dentro pod-0 — JS letto da stdin per non esporre credenziali in argv # (kubectl exec mette argv nei log audit dell'API server e nel command line del kubelet). # Connessione mongosh dentro pod-0 con TLS verso 127.0.0.1. # - tls=true + tlsCAFile: valida il cert via la CA interna. # - tlsAllowInvalidHostnames: il cert non include 'localhost' nei SAN; la CA però # è validata, quindi MITM resta impossibile (siamo già dentro al pod). # - 127.0.0.1: necessario per la "localhost exception" di mongo (creazione primo utente). # - directConnection=true: OBBLIGATORIO. Appena il replica set è inizializzato, # senza directConnection mongosh passa in modalità topology e prova a monitorare # il PRIMARY tramite l'hostname annunciato (mongodb-tenant-0.): la # connessione di monitoring si chiude e ogni comando fallisce con # "connection closed". directConnection forza la sessione sul solo # 127.0.0.1, restando compatibile con la localhost exception. MONGO_TLS_ARGS=('mongodb://127.0.0.1:27017/?directConnection=true&tls=true&tlsCAFile=/etc/mongo-tls/ca.crt&tlsAllowInvalidHostnames=true') mongo_eval() { # Localhost exception: usabile solo prima della creazione del primo utente. kc exec -i "$POD0" -c mongodb -- mongosh --quiet "${MONGO_TLS_ARGS[@]}" } mongo_eval_root() { # Autentica via db.auth() inline così -p non finisce in argv. # ROOT_USER/ROOT_PASS sono alfanumerici (openssl rand -hex), single-quote-safe. { printf "db.getSiblingDB('admin').auth('%s', '%s');\n" "$ROOT_USER" "$ROOT_PASS" cat } | kc exec -i "$POD0" -c mongodb -- mongosh --quiet "${MONGO_TLS_ARGS[@]}" } # Estrai l'unico valore stampato dal JS come `print('@@RES@@' + value)`. # Robusto rispetto a warning/deprecation/banner che mongosh può emettere. # Lo script JS passato deve usare la sentinella esplicitamente. mongo_get() { mongo_eval 2>/dev/null | sed -n 's/^@@RES@@//p' | head -1; } mongo_get_root() { mongo_eval_root 2>/dev/null | sed -n 's/^@@RES@@//p' | head -1; } # ----------------------------------------------------------------------------- # Pre-flight # ----------------------------------------------------------------------------- log "Pre-flight checks" command -v kubectl >/dev/null || err "kubectl non trovato in PATH" command -v openssl >/dev/null || err "openssl non trovato in PATH" command -v envsubst >/dev/null || err "envsubst non trovato in PATH (brew install gettext && brew link gettext --force)" ok "Strumenti disponibili" # ----------------------------------------------------------------------------- # Prompt dei parametri # ----------------------------------------------------------------------------- log "Parametri del deploy" CURRENT_CTX=$(kubectl config current-context 2>/dev/null || echo "") echo "Context kubectl disponibili:" kubectl config get-contexts -o name | sed 's/^/ - /' echo ask "Context da usare" "$CURRENT_CTX" CONTEXT="$REPLY" [[ -z "$CONTEXT" ]] && err "Context obbligatorio" # Verifica accesso al cluster kubectl --context "$CONTEXT" cluster-info >/dev/null 2>&1 \ || err "Impossibile contattare il cluster sul context '$CONTEXT'" ok "Connesso a $CONTEXT" ask "Nome del namespace" "$DEF_NAMESPACE" NAMESPACE="$REPLY" [[ -z "$NAMESPACE" ]] && err "Namespace obbligatorio" # Re-run protection: questo script genera segreti freschi ad ogni esecuzione. # Se il namespace contiene già un deploy, sovrascrivere i Secret causerebbe: # - keyfile Mongo nuovo → mismatch fra membri del replica set → perdita di quorum. # - password app/root nel Secret diverse da quelle in DB → API e mongo_eval_root rotti. # Per fresh install: cancellare il namespace. Per update immagini: usare update.sh. if kubectl --context "$CONTEXT" -n "$NAMESPACE" get secret mongodb-tenant-auth >/dev/null 2>&1; then err "Il namespace '$NAMESPACE' contiene già un deploy (Secret 'mongodb-tenant-auth' presente). Rilanciare deploy.sh genererebbe nuovi segreti e rompere il replica set. - Per aggiornare le immagini: ./update.sh - Per ridistribuire da zero: kubectl --context '$CONTEXT' delete namespace '$NAMESPACE'" fi # Calcolo FQDN MongoDB (dipende dal namespace scelto) FQDN_SUFFIX="${SVC_HEADLESS}.${NAMESPACE}.svc.cluster.local" HOST0="${POD0}.${FQDN_SUFFIX}:27017" HOST1="${POD1}.${FQDN_SUFFIX}:27017" HOST2="${POD2}.${FQDN_SUFFIX}:27017" # I due tag possono divergere: storicamente api-tenant viene rilasciata più # spesso di admin-tenant, quindi due prompt separati invece di uno unico. ask "Tag api-tenant" "$DEF_IMAGE_TAG_API" IMAGE_TAG_API="$REPLY" ask "Tag admin-tenant" "$DEF_IMAGE_TAG_ADMIN" IMAGE_TAG_ADMIN="$REPLY" ask "Hostname pubblico API" "$DEF_DOMAIN_API" DOMAIN_API="$REPLY" ask "Hostname pubblico Admin" "$DEF_DOMAIN_ADMIN" DOMAIN_ADMIN="$REPLY" TENANT_ADMIN_URL="https://${DOMAIN_ADMIN}" ask "LOGINMASTER_API_URL (API LoginMaster centrale)" "$DEF_LOGINMASTER_API_URL" LOGINMASTER_API_URL="$REPLY" # ADMIN_ALLOWED_ORIGIN è l'URL del pannello admin GLOBALE di LoginMaster (es. # https://admin.loginmaster.it), non del tenant: serve all'API per accettare # richieste cross-origin di amministrazione centralizzata. È deliberatamente # diverso da TENANT_ADMIN_URL (https://${DOMAIN_ADMIN}, calcolato sotto). ask "ADMIN_ALLOWED_ORIGIN (origin del pannello LoginMaster globale)" "$DEF_ADMIN_ALLOWED_ORIGIN" ADMIN_ALLOWED_ORIGIN="$REPLY" # Default suggerito = la storage class marcata come default sul cluster (annotazione # storageclass.kubernetes.io/is-default-class). Funziona indipendentemente dal cloud # provider (gp3 su EKS, managed-csi su AKS, do-block-storage su DOKS, sbs-default-retain # su Scaleway, vsphere-csi su vSphere, ecc.). Se il cluster non ne ha una marcata, niente default. DETECTED_SC=$(kubectl --context "$CONTEXT" get storageclass -o jsonpath='{.items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")].metadata.name}' 2>/dev/null | awk '{print $1}') ask "Storage class per i PVC Mongo" "${DETECTED_SC:-}" STORAGE_CLASS="$REPLY" [[ -z "$STORAGE_CLASS" ]] && err "Storage class obbligatoria" ask "Storage size per ogni membro Mongo" "$DEF_STORAGE_SIZE" STORAGE_SIZE="$REPLY" ask "Mongo CPU request" "$DEF_MONGO_CPU_REQUEST" MONGO_CPU_REQUEST="$REPLY" ask "Mongo CPU limit" "$DEF_MONGO_CPU_LIMIT" MONGO_CPU_LIMIT="$REPLY" ask "Mongo memory request" "$DEF_MONGO_MEM_REQUEST" MONGO_MEM_REQUEST="$REPLY" ask "Mongo memory limit" "$DEF_MONGO_MEM_LIMIT" MONGO_MEM_LIMIT="$REPLY" # SMTP opzionale SMTP_HOST=""; SMTP_PORT="587"; SMTP_SECURE="false"; SMTP_USER=""; SMTP_PASSWORD=""; EMAIL_FROM="" if confirm "Configurare SMTP ora?" "N"; then ask "SMTP host (es. smtp.gmail.com)" "" SMTP_HOST="$REPLY" ask "SMTP port" "587" SMTP_PORT="$REPLY" ask "SMTP secure (true/false)" "false" SMTP_SECURE="$REPLY" ask "SMTP user" "" SMTP_USER="$REPLY" ask_secret "SMTP password" SMTP_PASSWORD="$REPLY" ask "EMAIL_FROM (es. 'Tenant ')" "" EMAIL_FROM="$REPLY" fi # Registry codebaker log "Credenziali registry ${REGISTRY_SERVER}" ask "Registry username" "$DEF_REGISTRY_USERNAME" REGISTRY_USERNAME="$REPLY" [[ -z "$REGISTRY_USERNAME" ]] && err "Username registry obbligatorio" ask_secret "Registry password" REGISTRY_PASSWORD="$REPLY" [[ -z "$REGISTRY_PASSWORD" ]] && err "Password registry obbligatoria" # ----------------------------------------------------------------------------- # Generazione segreti # ----------------------------------------------------------------------------- log "Genero segreti crittografici" MASTER_ENCRYPTION_KEY=$(openssl rand -hex 32) MONGO_KEYFILE=$(openssl rand -base64 756 | tr -d '\n') ROOT_USER="$DEF_ROOT_USER" ROOT_PASS=$(openssl rand -hex 24) APP_USER="$DEF_APP_USER" APP_PASS=$(openssl rand -hex 24) MONGODB_URI="mongodb://${APP_USER}:${APP_PASS}@${HOST0},${HOST1},${HOST2}/${DB_NAME}?replicaSet=${RS_NAME}&authSource=admin&readPreference=nearest&retryWrites=true&w=majority&tls=true&tlsCAFile=/etc/mongo-tls/ca.crt" ok "Segreti generati (verranno salvati in $CREDS_FILE al termine)" # ----------------------------------------------------------------------------- # Summary + conferma finale # ----------------------------------------------------------------------------- log "Riepilogo deploy" cat <}${SMTP_HOST:+ (user=$SMTP_USER)} Registry: ${REGISTRY_SERVER} (user=$REGISTRY_USERNAME) Workloads: mongodb=3 (replica set rs0) / tenant-api=2 / tenant-admin=1 Mongo root user: $ROOT_USER Mongo app user: $APP_USER (db=$DB_NAME) EOF confirm "Procedere con il deploy?" "N" || { echo "Annullato."; exit 0; } # ----------------------------------------------------------------------------- # Apply risorse # ----------------------------------------------------------------------------- # Esporta tutte le variabili per envsubst (usato da render in ogni step) export NAMESPACE LOGINMASTER_API_URL ADMIN_ALLOWED_ORIGIN TENANT_ADMIN_URL \ SMTP_HOST SMTP_PORT SMTP_SECURE SMTP_USER EMAIL_FROM \ STORAGE_CLASS STORAGE_SIZE IMAGE_TAG_API IMAGE_TAG_ADMIN DOMAIN_API DOMAIN_ADMIN \ MONGO_CPU_REQUEST MONGO_CPU_LIMIT MONGO_MEM_REQUEST MONGO_MEM_LIMIT log "1/11 Namespace" render "$TEMPLATES_DIR/00-namespace.yaml" | kc_cluster apply -f - log "2/11 NetworkPolicy (isolamento intra-namespace)" render "$TEMPLATES_DIR/02-networkpolicy.yaml" | kc_cluster apply -f - log "3/11 cert-manager: CA interna + cert membri replica set" render "$TEMPLATES_DIR/01-mongodb-tls.yaml" | kc_cluster apply -f - log "4/11 Secret registry-codebaker" kc create secret docker-registry registry-codebaker \ --docker-server="$REGISTRY_SERVER" \ --docker-username="$REGISTRY_USERNAME" \ --docker-password="$REGISTRY_PASSWORD" \ --dry-run=client -o yaml | kc apply -f - log "5/11 Secret tenant-api-secrets (MASTER_ENCRYPTION_KEY + SMTP_PASSWORD)" kc create secret generic tenant-api-secrets \ --from-literal=MASTER_ENCRYPTION_KEY="$MASTER_ENCRYPTION_KEY" \ --from-literal=SMTP_PASSWORD="$SMTP_PASSWORD" \ --dry-run=client -o yaml | kc apply -f - log "6/11 Secret mongodb-tenant-auth (keyfile + credenziali + URI)" kc create secret generic mongodb-tenant-auth \ --from-literal=keyfile="$MONGO_KEYFILE" \ --from-literal=root-username="$ROOT_USER" \ --from-literal=root-password="$ROOT_PASS" \ --from-literal=app-username="$APP_USER" \ --from-literal=app-password="$APP_PASS" \ --from-literal=app-database="$DB_NAME" \ --from-literal=MONGODB_URI="$MONGODB_URI" \ --dry-run=client -o yaml | kc apply -f - log "7/11 ConfigMap tenant-api-config" render "$TEMPLATES_DIR/03-configmap.yaml" | kc_cluster apply -f - log "8/11 MongoDB StatefulSet + Service + PDB" log " Attendo che cert-manager emetta il Secret 'mongodb-tenant-tls'" for i in $(seq 1 60); do if kc get secret mongodb-tenant-tls >/dev/null 2>&1; then ok "Cert TLS emesso" break fi [[ $i -eq 60 ]] && err "cert-manager non ha emesso 'mongodb-tenant-tls' entro 2 minuti. Verifica: kubectl --context '$CONTEXT' -n '$NAMESPACE' get certificate,certificaterequest" sleep 2 done render "$TEMPLATES_DIR/04-mongodb.yaml" | kc_cluster apply -f - log " Attendo che i 3 membri ($POD0, $POD1, $POD2) siano Ready (timeout 10 min — il provisioning PVC+attach può essere lento)" # kubectl wait sfrutta direttamente la readiness probe dei pod. Dieci minuti # coprono provisioning PVC + attach + boot Mongo anche su CSI lenti (vSphere, # Scaleway block storage in cold start, ecc.). # IMPORTANTE: aspettiamo TUTTI e 3 i pod, non solo pod-0. Il record DNS per-pod # del service headless (es. mongodb-tenant-1.) viene pubblicato solo # quando il pod è Ready: lanciare rs.initiate con il solo pod-0 pronto fa fallire # il quorum check con "Could not find address for mongodb-tenant-1...". Questo # accade quando pod-0 parte veloce e 1/2 sono ancora in provisioning PVC. if ! kc wait "pod/$POD0" "pod/$POD1" "pod/$POD2" --for=condition=Ready --timeout=600s 2>&1; then err "Uno dei pod mongodb-tenant-{0,1,2} non è Ready dopo 10 minuti. Diagnostica: kubectl --context '$CONTEXT' -n '$NAMESPACE' get pods kubectl --context '$CONTEXT' -n '$NAMESPACE' describe pod $POD0 kubectl --context '$CONTEXT' -n '$NAMESPACE' logs $POD0 -c mongodb" fi ok "I 3 membri del replica set sono Ready" # ----------------------------------------------------------------------------- # Init replica set + utenti # ----------------------------------------------------------------------------- log "9/11 Inizializzo il replica set $RS_NAME" set +e RS_OK=$(echo "try { print('@@RES@@' + rs.status().ok) } catch(e) { print('@@RES@@0') }" | mongo_get) set -e if [[ "$RS_OK" != "1" ]]; then log " rs.initiate con 3 membri (fresh install, tutti i PVC sono vuoti)" mongo_eval <m.self).stateStr) } catch(e) { print('@@RES@@') }" | mongo_get) [[ "$STATE" == *"PRIMARY"* ]] && { ok "$POD0 è PRIMARY"; break; } [[ $i -eq 150 ]] && err "Nessun PRIMARY eletto entro 5 minuti. Diagnostica: kc exec $POD0 -c mongodb -- mongosh --quiet \\ 'mongodb://127.0.0.1:27017/?directConnection=true&tls=true&tlsCAFile=/etc/mongo-tls/ca.crt&tlsAllowInvalidHostnames=true' \\ --eval 'rs.status()'" sleep 2 done else ok "Replica set $RS_NAME già inizializzato" fi log " Verifico/creo utente root '$ROOT_USER' (localhost exception)" HAS_ROOT=$(echo "try { print('@@RES@@' + db.getSiblingDB('admin').system.users.countDocuments({user:'${ROOT_USER}'})) } catch(e) { print('@@RES@@0') }" | mongo_get || echo 0) if [[ "$HAS_ROOT" != "1" ]]; then mongo_eval <m.stateStr=='PRIMARY'||m.stateStr=='SECONDARY').length)" | mongo_get_root || echo 0) if [[ "$HEALTHY" == "3" ]]; then ok "Replica set sano: 1 PRIMARY + 2 SECONDARY" break fi echo " membri sani: ${HEALTHY}/3 — attendo..." [[ $i -eq 120 ]] && err "Replica set non sano dopo 10 min" sleep 5 done # ----------------------------------------------------------------------------- # App workloads # ----------------------------------------------------------------------------- log "10/11 tenant-api (Service + Deployment + PDB)" render "$TEMPLATES_DIR/05-tenant-api.yaml" | kc_cluster apply -f - kc rollout status deployment/tenant-api --timeout=300s log " tenant-admin (Service + Deployment)" render "$TEMPLATES_DIR/06-tenant-admin.yaml" | kc_cluster apply -f - kc rollout status deployment/tenant-admin --timeout=300s log "11/11 Ingress" render "$TEMPLATES_DIR/07-ingress.yaml" | kc_cluster apply -f - # ----------------------------------------------------------------------------- # Salvataggio credenziali # ----------------------------------------------------------------------------- log "Salvo le credenziali in $CREDS_FILE" umask 077 cat > "$CREDS_FILE" <