#!/usr/bin/env bash # dockerize_app — Empaqueta una app del registry para deploy a VPS organic-machine # via Docker + Traefik + Coolify. Genera Dockerfile, docker-compose.yml, # traefik-dynamic.yml, sube via rsync y arranca el stack remoto. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)" source "$SCRIPT_DIR/../infra/rsync_deploy.sh" # --------------------------------------------------------------------------- # _dockerize_app_usage — imprime ayuda y sale con error # --------------------------------------------------------------------------- _dockerize_app_usage() { cat >&2 <<'USAGE' Uso: dockerize_app [opciones] --domain DOMAIN Dominio público (ej: kanban.organic-machine.com) --port PORT Puerto interno del contenedor (default: 8080) --ssh-host HOST Host SSH destino (default: organic-machine.com) --remote-dir DIR Directorio remoto (default: /home/ubuntu/coolify-apps/) --basic-auth USER:PASS Credenciales basicAuth para Traefik --no-auth Deshabilitar basicAuth (por defecto: auth ON) --no-gzip Deshabilitar gzip middleware --env KEY=VAL Variable de entorno (repetible) --volume NAME Volume Docker (se monta en /data) --build-cmd CMD Comando de build personalizado --standalone Crear repo Gitea + git clone remoto en vez de rsync --dry-run Mostrar artefactos generados sin ejecutar nada USAGE return 1 } # --------------------------------------------------------------------------- # _dockerize_app_generate_dockerfile — genera Dockerfile multi-stage para Go # --------------------------------------------------------------------------- _dockerize_app_generate_dockerfile() { local binary_name="$1" local port="$2" shift 2 local env_vars=("$@") cat </dev/null; then echo " WARNING: .env ya contiene '${key}', no se sobreescribe." >&2 else echo "${key}=${val}" >> "$env_file" fi done } # --------------------------------------------------------------------------- # dockerize_app — punto de entrada principal # --------------------------------------------------------------------------- dockerize_app() { local app_name="${1:-}" if [[ -z "$app_name" ]]; then _dockerize_app_usage fi shift # Defaults local domain="" local port=8080 local ssh_host="organic-machine.com" local remote_dir="" local basic_auth="" local no_auth=false local no_gzip=false local env_vars=() local volume_name="" local build_cmd="" local standalone=false local dry_run=false # Parse args while [[ $# -gt 0 ]]; do case "$1" in --domain) domain="$2"; shift 2 ;; --port) port="$2"; shift 2 ;; --ssh-host) ssh_host="$2"; shift 2 ;; --remote-dir) remote_dir="$2"; shift 2 ;; --basic-auth) basic_auth="$2"; shift 2 ;; --no-auth) no_auth=true; shift ;; --no-gzip) no_gzip=true; shift ;; --env) env_vars+=("$2"); shift 2 ;; --volume) volume_name="$2"; shift 2 ;; --build-cmd) build_cmd="$2"; shift 2 ;; --standalone) standalone=true; shift ;; --dry-run) dry_run=true; shift ;; *) echo "dockerize_app: opcion desconocida '$1'" >&2; _dockerize_app_usage ;; esac done # Validar que el dominio fue dado if [[ -z "$domain" ]]; then echo "dockerize_app: --domain es obligatorio" >&2 return 1 fi # Auth logic local auth_enabled=false if [[ "$no_auth" == "false" ]]; then auth_enabled=true if [[ -z "$basic_auth" ]]; then echo "dockerize_app: --basic-auth USER:PASS es obligatorio cuando auth esta ON. Usa --no-auth para deshabilitarlo." >&2 return 1 fi fi local enable_gzip="true" if [[ "$no_gzip" == "true" ]]; then enable_gzip="false" fi local start_ts start_ts=$(date +%s) echo "==> [1/13] Validando app '${app_name}' en registry.db..." >&2 # Buscar registry.db local registry_db="${FN_REGISTRY_ROOT:-$REGISTRY_ROOT}/registry.db" if [[ ! -f "$registry_db" ]]; then echo "dockerize_app: registry.db no encontrado en '$registry_db'" >&2 return 1 fi # Consultar la app en la BD local app_row app_row=$(sqlite3 "$registry_db" \ "SELECT dir_path || '|' || lang || '|' || name FROM apps WHERE id LIKE '${app_name}%' OR name = '${app_name}' LIMIT 1;" 2>/dev/null || true) if [[ -z "$app_row" ]]; then echo "dockerize_app: app '${app_name}' no encontrada en registry.db" >&2 echo " Consulta apps disponibles: sqlite3 '$registry_db' \"SELECT id, name, lang FROM apps ORDER BY name;\"" >&2 return 1 fi local app_dir_rel="${app_row%%|*}" local rest="${app_row#*|}" local app_lang="${rest%%|*}" local app_real_name="${rest#*|}" local app_dir="${REGISTRY_ROOT}/${app_dir_rel}" echo " OK: '${app_real_name}' (lang=${app_lang}) en '${app_dir_rel}'" >&2 # Fase 1: solo Go if [[ "$app_lang" != "go" ]]; then echo "dockerize_app: Phase 1 soporta solo apps Go. Lang detectado: '${app_lang}'" >&2 return 1 fi if [[ ! -d "$app_dir" ]]; then echo "dockerize_app: directorio de la app no encontrado: '$app_dir'" >&2 return 1 fi # Default remote_dir if [[ -z "$remote_dir" ]]; then remote_dir="/home/ubuntu/coolify-apps/${app_real_name}" fi echo "==> [2/13] Validando conectividad SSH a '${ssh_host}'..." >&2 if [[ "$dry_run" == "false" ]]; then if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$ssh_host" true 2>/dev/null; then echo "dockerize_app: no se puede conectar a '${ssh_host}' via SSH" >&2 return 1 fi echo " OK: SSH conectado." >&2 else echo " [dry-run] Saltando verificacion SSH." >&2 fi # Generar bcrypt si auth ON local basic_auth_line="" local basic_auth_user="" if [[ "$auth_enabled" == "true" ]]; then echo "==> [3/13] Generando hash bcrypt para basicAuth..." >&2 basic_auth_user="${basic_auth%%:*}" local basic_auth_pass="${basic_auth#*:}" if [[ -z "$basic_auth_user" || -z "$basic_auth_pass" ]]; then echo "dockerize_app: --basic-auth debe tener formato USER:PASS" >&2 return 1 fi if command -v htpasswd &>/dev/null; then basic_auth_line=$(htpasswd -nbB "$basic_auth_user" "$basic_auth_pass" 2>/dev/null) elif command -v python3 &>/dev/null; then # Fallback: bcrypt via python3 si está disponible basic_auth_line=$(python3 -c " import bcrypt, sys user, pw = sys.argv[1], sys.argv[2] h = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) print(f'{user}:{h.decode()}') " "$basic_auth_user" "$basic_auth_pass" 2>/dev/null) || { echo "dockerize_app: ni htpasswd ni python3+bcrypt disponibles para generar hash" >&2 return 1 } else echo "dockerize_app: 'htpasswd' no encontrado. Instalar con: sudo apt install apache2-utils" >&2 return 1 fi if [[ -z "$basic_auth_line" ]]; then echo "dockerize_app: no se pudo generar hash bcrypt" >&2 return 1 fi echo " OK: hash generado para usuario '${basic_auth_user}'." >&2 else echo "==> [3/13] BasicAuth deshabilitado (--no-auth)." >&2 fi # ----------------------------------------------------------------------- # Generar Dockerfile # ----------------------------------------------------------------------- echo "==> [4/13] Generando Dockerfile..." >&2 local dockerfile_path="${app_dir}/Dockerfile" local dockerfile_content if [[ ${#env_vars[@]} -gt 0 ]]; then dockerfile_content=$(_dockerize_app_generate_dockerfile \ "$app_real_name" \ "$port" \ "${env_vars[@]}" 2>/dev/null || true) else dockerfile_content=$(_dockerize_app_generate_dockerfile \ "$app_real_name" \ "$port" 2>/dev/null || true) fi if [[ "$dry_run" == "true" ]]; then echo "--- Dockerfile (${dockerfile_path}) ---" >&2 echo "$dockerfile_content" >&2 echo "---" >&2 elif [[ -f "$dockerfile_path" ]]; then echo " INFO: Dockerfile ya existe en '${dockerfile_path}', no se sobreescribe." >&2 else echo "$dockerfile_content" > "$dockerfile_path" echo " OK: Dockerfile generado en '${dockerfile_path}'." >&2 fi # ----------------------------------------------------------------------- # Generar docker-compose.yml # ----------------------------------------------------------------------- echo "==> [5/13] Generando docker-compose.yml..." >&2 local compose_path="${app_dir}/docker-compose.yml" # Build context relativo al remote_dir (contexto remoto) # Localmente el compose vive en apps//, el contexto del build Docker # apunta al root del repo (../../) para que el Dockerfile pueda hacer COPY . . local build_context="../../" local dockerfile_in_compose="${app_dir_rel}/Dockerfile" local compose_content if [[ ${#env_vars[@]} -gt 0 ]]; then compose_content=$(_dockerize_app_generate_compose \ "$app_real_name" \ "$app_real_name" \ "$build_context" \ "$dockerfile_in_compose" \ "$port" \ "$volume_name" \ "coolify" \ "${env_vars[@]}" 2>/dev/null || true) else compose_content=$(_dockerize_app_generate_compose \ "$app_real_name" \ "$app_real_name" \ "$build_context" \ "$dockerfile_in_compose" \ "$port" \ "$volume_name" \ "coolify" 2>/dev/null || true) fi if [[ "$dry_run" == "true" ]]; then echo "--- docker-compose.yml (${compose_path}) ---" >&2 echo "$compose_content" >&2 echo "---" >&2 else echo "$compose_content" > "$compose_path" echo " OK: docker-compose.yml generado en '${compose_path}'." >&2 fi # ----------------------------------------------------------------------- # Generar traefik-dynamic.yml # ----------------------------------------------------------------------- echo "==> [6/13] Generando traefik-dynamic.yml..." >&2 local traefik_path="${app_dir}/traefik-dynamic.yml" local upstream_url="http://${app_real_name}:${port}" # Nombre del router Traefik: reemplazar _ por - para nombres válidos local traefik_name="${app_real_name//_/-}" local traefik_content traefik_content=$(_dockerize_app_generate_traefik_dynamic \ "$traefik_name" \ "$domain" \ "$upstream_url" \ "$basic_auth_line" \ "$enable_gzip" 2>/dev/null || true) if [[ "$dry_run" == "true" ]]; then echo "--- traefik-dynamic.yml (${traefik_path}) ---" >&2 echo "$traefik_content" >&2 echo "---" >&2 else echo "$traefik_content" > "$traefik_path" echo " OK: traefik-dynamic.yml generado en '${traefik_path}'." >&2 fi # ----------------------------------------------------------------------- # Generar .env # ----------------------------------------------------------------------- local env_path="${app_dir}/.env" if [[ ${#env_vars[@]} -gt 0 ]]; then echo "==> [7/13] Generando/actualizando .env..." >&2 if [[ "$dry_run" == "true" ]]; then echo "--- .env (${env_path}) ---" >&2 for kv in "${env_vars[@]}"; do echo "$kv"; done >&2 echo "---" >&2 elif [[ -f "$env_path" ]]; then echo " INFO: .env ya existe, aplicando merge no destructivo." >&2 _dockerize_app_merge_env_file "$env_path" "${env_vars[@]}" else for kv in "${env_vars[@]}"; do echo "$kv"; done > "$env_path" echo " OK: .env creado en '${env_path}'." >&2 fi else echo "==> [7/13] Sin vars --env, omitiendo .env." >&2 fi if [[ "$dry_run" == "true" ]]; then local end_ts end_ts=$(date +%s) local duration=$(( end_ts - start_ts )) echo "" >&2 echo "[dry-run] Artefactos generados. Sin cambios remotos." >&2 printf '{"status":"dry-run","app":"%s","domain":"%s","remote_dir":"%s","port":%d,"auth_enabled":%s,"gzip_enabled":%s,"duration_seconds":%d,"url":"https://%s"}\n' \ "$app_real_name" "$domain" "$remote_dir" "$port" \ "$auth_enabled" "$enable_gzip" "$duration" "$domain" return 0 fi # ----------------------------------------------------------------------- # Standalone: crear repo Gitea + push # ----------------------------------------------------------------------- if [[ "$standalone" == "true" ]]; then echo "==> [8/13] Creando repo Gitea (standalone mode)..." >&2 source "$SCRIPT_DIR/../infra/gitea_create_repo.sh" source "$SCRIPT_DIR/../infra/gitea_push_directory.sh" gitea_create_repo "dataforge" "$app_real_name" "true" "App ${app_real_name} — dockerized via dockerize_app" >&2 || true gitea_push_directory "$app_dir" "dataforge" "$app_real_name" "master" >&2 echo " OK: repo Gitea 'dataforge/${app_real_name}' sincronizado." >&2 echo "==> [9/13] Clonando/actualizando repo en remoto (standalone mode)..." >&2 local gitea_base="${GITEA_URL:-https://git.organic-machine.com}" ssh "$ssh_host" bash <&2 else echo "==> [8/13] Rsync del app al VPS..." >&2 # Sincronizar solo el directorio de la app (más liviano que el repo completo) # El build context remoto apunta a ../../ desde remote_dir, por lo que # también necesitamos los ficheros Go del root del repo. # Solución: rsync el root del registry al VPS en un dir de build, y además # sincronizar los artefactos Docker generados. local remote_build_root="/home/ubuntu/coolify-build/${app_real_name}" echo " Sincronizando repo completo a '${ssh_host}:${remote_build_root}'..." >&2 rsync_deploy "${REGISTRY_ROOT}/" "$ssh_host" "$remote_build_root" > /dev/null echo " OK: repo sincronizado en '${remote_build_root}'." >&2 echo "==> [9/13] Preparando directorio remoto de deploy..." >&2 ssh "$ssh_host" "mkdir -p '${remote_dir}'" echo " OK: '${remote_dir}' disponible." >&2 fi # ----------------------------------------------------------------------- # Subir traefik-dynamic.yml al proxy de Coolify # ----------------------------------------------------------------------- echo "==> [10/13] Subiendo traefik-dynamic.yml a Coolify proxy..." >&2 local traefik_coolify_path="/data/coolify/proxy/dynamic/${traefik_name}.yml" ssh "$ssh_host" "sudo mkdir -p /data/coolify/proxy/dynamic/" >&2 echo "$traefik_content" | ssh "$ssh_host" \ "sudo tee '${traefik_coolify_path}' > /dev/null" echo " OK: traefik-dynamic.yml en '${traefik_coolify_path}'." >&2 # ----------------------------------------------------------------------- # Subir docker-compose.yml, Dockerfile y .env al remote_dir # ----------------------------------------------------------------------- echo "==> [11/13] Subiendo artefactos Docker a '${ssh_host}:${remote_dir}'..." >&2 if [[ "$standalone" == "true" ]]; then # En standalone, el repo ya está clonado en remote_dir echo " [standalone] Artefactos ya en remote_dir via git." >&2 else # Copiar los artefactos generados al remote_dir (que es el subdir de la app en el build root) local remote_app_subdir="${remote_build_root}/${app_dir_rel}" ssh "$ssh_host" "mkdir -p '${remote_app_subdir}'" >&2 scp "$compose_path" "${ssh_host}:${remote_app_subdir}/docker-compose.yml" >&2 scp "$dockerfile_path" "${ssh_host}:${remote_app_subdir}/Dockerfile" >&2 if [[ -f "$env_path" ]]; then scp "$env_path" "${ssh_host}:${remote_app_subdir}/.env" >&2 fi # Apuntar remote_dir al subdir donde está el compose remote_dir="$remote_app_subdir" echo " OK: artefactos subidos a '${remote_app_subdir}'." >&2 fi # ----------------------------------------------------------------------- # Verificar red coolify + docker compose up --build # ----------------------------------------------------------------------- echo "==> [12/13] Verificando red Docker 'coolify' y levantando stack..." >&2 ssh "$ssh_host" bash <&2 # ----------------------------------------------------------------------- # Health check # ----------------------------------------------------------------------- echo "==> [13/13] Health check en 'https://${domain}'..." >&2 local health_url="https://${domain}/" local attempts=0 local max_attempts=10 local http_code="000" while [[ $attempts -lt $max_attempts ]]; do if [[ -n "$basic_auth_user" ]]; then http_code=$(curl -sk -o /dev/null -w "%{http_code}" \ -u "${basic_auth_user}:${basic_auth#*:}" \ "$health_url" 2>/dev/null || echo "000") else http_code=$(curl -sk -o /dev/null -w "%{http_code}" \ "$health_url" 2>/dev/null || echo "000") fi # 200 = OK, 401 = basicAuth activo (correcto), 301/302 = redirect (transitorio) if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then break fi attempts=$(( attempts + 1 )) echo " Intento ${attempts}/${max_attempts} — HTTP ${http_code}, esperando 3s..." >&2 sleep 3 done local end_ts end_ts=$(date +%s) local duration=$(( end_ts - start_ts )) # Obtener container_id local container_id="" container_id=$(ssh "$ssh_host" \ "docker ps --filter name=${app_real_name} --format '{{.ID}}' | head -1" 2>/dev/null || true) if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then echo " OK: servicio respondiendo HTTP ${http_code}." >&2 local status_val="ok" else echo " ERROR: health check timeout tras ${max_attempts} intentos." >&2 local status_val="failed" fi printf '{"status":"%s","app":"%s","domain":"%s","remote_dir":"%s","container_id":"%s","duration_seconds":%d,"auth_enabled":%s,"gzip_enabled":%s,"http_code":"%s","url":"https://%s"}\n' \ "$status_val" \ "$app_real_name" \ "$domain" \ "$remote_dir" \ "$container_id" \ "$duration" \ "$auth_enabled" \ "$enable_gzip" \ "$http_code" \ "$domain" [[ "$status_val" == "ok" ]] } # --------------------------------------------------------------------------- # Punto de entrada directo (bash dockerize_app.sh [flags]) # --------------------------------------------------------------------------- if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then dockerize_app "$@" fi