Files
fn_registry/bash/functions/pipelines/dockerize_app.sh
T
egutierrez 750b7abcd5 chore: auto-commit (97 archivos)
- .claude/CLAUDE.md
- .claude/agents/fn-recopilador/SKILL.md
- .claude/rules/INDEX.md
- .claude/rules/cpp_apps.md
- bash/functions/infra/build_cpp_windows.sh
- cpp/CMakeLists.txt
- cpp/PATTERNS.md
- cpp/framework/app_base.cpp
- cpp/framework/app_base.h
- dev/issues/README.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:11:24 +02:00

686 lines
24 KiB
Bash

#!/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 <app_name> [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/<app>)
--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 <<DOCKERFILE
# Stage build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o ${binary_name} .
# Stage final
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/${binary_name} .
DOCKERFILE
if [[ ${#env_vars[@]} -gt 0 ]]; then
for kv in "${env_vars[@]}"; do
[[ -n "$kv" ]] && echo "ENV ${kv%%=*}=${kv#*=}"
done
echo ""
fi
cat <<DOCKERFILE
EXPOSE ${port}
ENTRYPOINT ["./${binary_name}"]
DOCKERFILE
}
# ---------------------------------------------------------------------------
# _dockerize_app_generate_compose — genera docker-compose.yml
# ---------------------------------------------------------------------------
_dockerize_app_generate_compose() {
local project_name="$1"
local service_name="$2"
local build_context="$3"
local dockerfile_path="$4"
local port="$5"
local volume_name="$6"
local network="$7"
shift 7
local env_vars=("$@")
cat <<COMPOSE
name: ${project_name}
services:
${service_name}:
build:
context: ${build_context}
dockerfile: ${dockerfile_path}
container_name: ${service_name}
restart: unless-stopped
ports:
- "${port}:${port}"
COMPOSE
if [[ -n "$volume_name" ]]; then
cat <<COMPOSE
volumes:
- ${volume_name}:/data
COMPOSE
fi
if [[ ${#env_vars[@]} -gt 0 ]]; then
echo " environment:"
for kv in "${env_vars[@]}"; do
local key="${kv%%=*}"
echo " - ${key}=\${${key}:-}"
done
fi
cat <<COMPOSE
networks:
- ${network}
COMPOSE
if [[ -n "$volume_name" ]]; then
cat <<COMPOSE
volumes:
${volume_name}:
COMPOSE
fi
cat <<COMPOSE
networks:
${network}:
external: true
COMPOSE
}
# ---------------------------------------------------------------------------
# _dockerize_app_generate_traefik_dynamic — genera traefik-dynamic.yml
# ---------------------------------------------------------------------------
_dockerize_app_generate_traefik_dynamic() {
local name="$1"
local domain="$2"
local upstream_url="$3"
local basic_auth_line="$4" # "" para deshabilitar
local enable_gzip="$5" # "true" | "false"
# Construir lista de middlewares HTTPS
local https_middlewares=()
if [[ -n "$basic_auth_line" ]]; then
https_middlewares+=("${name}-auth")
fi
if [[ "$enable_gzip" == "true" ]]; then
https_middlewares+=("${name}-gzip")
fi
cat <<TRAEFIK
http:
routers:
${name}-http:
rule: "Host(\`${domain}\`)"
entryPoints:
- "http"
middlewares:
- "${name}-redirect"
service: "${name}-service"
${name}-https:
rule: "Host(\`${domain}\`)"
entryPoints:
- "https"
TRAEFIK
if [[ ${#https_middlewares[@]} -gt 0 ]]; then
echo " middlewares:"
for mw in "${https_middlewares[@]}"; do
echo " - \"${mw}\""
done
fi
cat <<TRAEFIK
service: "${name}-service"
tls:
certResolver: letsencrypt
services:
${name}-service:
loadBalancer:
servers:
- url: "${upstream_url}"
middlewares:
${name}-redirect:
redirectScheme:
scheme: "https"
TRAEFIK
if [[ -n "$basic_auth_line" ]]; then
cat <<TRAEFIK
${name}-auth:
basicAuth:
users:
- "${basic_auth_line}"
TRAEFIK
fi
if [[ "$enable_gzip" == "true" ]]; then
cat <<TRAEFIK
${name}-gzip:
compress: true
TRAEFIK
fi
}
# ---------------------------------------------------------------------------
# _dockerize_app_merge_env_file — merge no destructivo de .env
# Agrega solo keys que no existen ya. Avisa de conflictos.
# ---------------------------------------------------------------------------
_dockerize_app_merge_env_file() {
local env_file="$1"
shift
local new_vars=("$@")
for kv in "${new_vars[@]}"; do
local key="${kv%%=*}"
local val="${kv#*=}"
if grep -q "^${key}=" "$env_file" 2>/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/<app>/, 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 <<REMOTE
set -euo pipefail
if [[ -d '${remote_dir}/.git' ]]; then
echo " git pull en '${remote_dir}'..."
cd '${remote_dir}' && git pull origin master
else
echo " git clone en '${remote_dir}'..."
mkdir -p '${remote_dir}'
git clone '${gitea_base}/dataforge/${app_real_name}.git' '${remote_dir}'
fi
REMOTE
echo " OK: repo remoto actualizado." >&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 <<REMOTE
set -euo pipefail
if ! docker network ls --format '{{.Name}}' | grep -q '^coolify$'; then
echo " Creando red Docker 'coolify'..."
docker network create coolify
fi
echo " Red 'coolify' disponible."
cd '${remote_dir}'
echo " docker compose build + up..."
docker compose up -d --build
echo " Stack levantado."
REMOTE
echo " OK: stack Docker activo." >&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 <app> [flags])
# ---------------------------------------------------------------------------
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
dockerize_app "$@"
fi