#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" STACK_ENV="${STACK_ENV:-$ROOT_DIR/stack.env}" STACK_ID="${GIA_STACK_ID:-${STACK_ID:-}}" STACK_ID="$(echo "$STACK_ID" | tr -cs 'a-zA-Z0-9._-' '-' | sed 's/^-*//; s/-*$//')" name_with_stack() { local base="$1" if [[ -n "$STACK_ID" ]]; then echo "${base}_${STACK_ID}" else echo "$base" fi } POD_NAME="$(name_with_stack "gia")" REDIS_CONTAINER="$(name_with_stack "redis_gia")" SIGNAL_CONTAINER="$(name_with_stack "signal")" MIGRATION_CONTAINER="$(name_with_stack "migration_gia")" COLLECTSTATIC_CONTAINER="$(name_with_stack "collectstatic_gia")" APP_CONTAINER="$(name_with_stack "gia")" ASGI_CONTAINER="$(name_with_stack "asgi_gia")" UR_CONTAINER="$(name_with_stack "ur_gia")" SCHED_CONTAINER="$(name_with_stack "scheduling_gia")" CODEX_WORKER_CONTAINER="$(name_with_stack "codex_worker_gia")" PROSODY_CONTAINER="$(name_with_stack "prosody_gia")" REDIS_DATA_DIR="${QUADLET_REDIS_DATA_DIR:-$ROOT_DIR/.podman/gia_redis_data}" WHATSAPP_DATA_DIR="${QUADLET_WHATSAPP_DATA_DIR:-$ROOT_DIR/.podman/gia_whatsapp_data}" SQLITE_DATA_DIR="${QUADLET_SQLITE_DATA_DIR:-$ROOT_DIR/.podman/gia_sqlite_data}" PROSODY_CONFIG_FILE="${QUADLET_PROSODY_CONFIG_FILE:-$ROOT_DIR/utilities/prosody/prosody.cfg.lua}" PROSODY_CERTS_DIR="${QUADLET_PROSODY_CERTS_DIR:-$ROOT_DIR/.podman/gia_prosody_certs}" PROSODY_DATA_DIR="${QUADLET_PROSODY_DATA_DIR:-$ROOT_DIR/.podman/gia_prosody_data}" PROSODY_LOGS_DIR="${QUADLET_PROSODY_LOGS_DIR:-$ROOT_DIR/.podman/gia_prosody_logs}" PROSODY_ENABLED="${PROSODY_ENABLED:-false}" ENSURE_XMPP_SECRET_SCRIPT="$ROOT_DIR/utilities/prosody/ensure_xmpp_secret.sh" if [[ -n "${STACK_ID}" ]]; then VRUN_DIR="/code/vrun/${STACK_ID}" else VRUN_DIR="/code/vrun" fi load_env() { set -a . "$STACK_ENV" set +a PROSODY_ENABLED="${PROSODY_ENABLED:-false}" if [[ -x "$ENSURE_XMPP_SECRET_SCRIPT" ]]; then XMPP_SECRET="$("$ENSURE_XMPP_SECRET_SCRIPT" "$STACK_ENV")" export XMPP_SECRET fi } is_remote() { [[ -n "${CONTAINER_HOST:-}" ]] || [[ -n "${PODMAN_HOST:-}" ]] || [[ -n "${DOCKER_HOST:-}" ]] } resolve_path() { local path="$1" if [[ "$path" = /* ]]; then echo "$path" else echo "$ROOT_DIR/$path" fi } require_podman() { if ! command -v podman >/dev/null 2>&1; then echo "podman not found" >&2 exit 1 fi } ensure_dirs() { mkdir -p "$REDIS_DATA_DIR" "$WHATSAPP_DATA_DIR" "$SQLITE_DATA_DIR" "$VRUN_DIR" "$ROOT_DIR/signal-cli-config" "$PROSODY_CERTS_DIR" "$PROSODY_DATA_DIR" "$PROSODY_LOGS_DIR" chmod 0777 "$SQLITE_DATA_DIR" 2>/dev/null || true # Container runs as uid 1000 (xf); rootless Podman remaps uids so plain # chown won't work — podman unshare translates to the correct host uid. if [[ -n "${QUADLET_SKIP_UNSHARE:-}" ]] || [[ -n "${CONTAINER_HOST:-}" ]] || [[ -n "${PODMAN_HOST:-}" ]] || [[ -n "${DOCKER_HOST:-}" ]]; then echo "Skipping podman unshare chown -- running in sandbox" else podman unshare chown 1000:1000 "$WHATSAPP_DATA_DIR" 2>/dev/null || true fi } rm_if_exists() { podman rm -f "$1" >/dev/null 2>&1 || true } wait_for_redis_socket() { local sock="$VRUN_DIR/gia-redis.sock" local i for i in $(seq 1 60); do [[ -S "$sock" ]] && return 0 sleep 1 done echo "redis socket did not appear at $sock" >&2 return 1 } run_worker_container() { local name="$1" local cmd="$2" local with_uwsgi="${3:-0}" local with_whatsapp="${4:-0}" rm_if_exists "$name" local args=( --replace --name "$name" --pod "$POD_NAME" --user "$(id -u):$(id -g)" --env-file "$STACK_ENV" --env "SIGNAL_HTTP_URL=http://127.0.0.1:8080" --env "APP_DATABASE_PATH=$APP_DATABASE_PATH" -v "$REPO_DIR:/code" -v "$SQLITE_DATA_DIR:/conf" -v "$VRUN_DIR:/var/run" ) if [[ "$with_uwsgi" == "1" ]]; then args+=( -v "$REPO_DIR/docker/uwsgi.ini:/conf/uwsgi.ini:ro" ) fi if [[ "$with_whatsapp" == "1" ]]; then args+=( -v "$WHATSAPP_DATA_DIR:${WHATSAPP_DB_DIR:-/var/tmp/whatsapp}" ) fi podman run -d "${args[@]}" localhost/xf/gia:prod sh -c "$cmd" >/dev/null } run_oneshot_container() { local name="$1" local cmd="$2" local with_whatsapp="${3:-0}" rm_if_exists "$name" local args=( --replace --name "$name" --pod "$POD_NAME" --user "$(id -u):$(id -g)" --env-file "$STACK_ENV" --env "SIGNAL_HTTP_URL=http://127.0.0.1:8080" --env "APP_DATABASE_PATH=$APP_DATABASE_PATH" -v "$REPO_DIR:/code" -v "$SQLITE_DATA_DIR:/conf" -v "$VRUN_DIR:/var/run" ) if [[ "$with_whatsapp" == "1" ]]; then args+=( -v "$WHATSAPP_DATA_DIR:${WHATSAPP_DB_DIR:-/var/tmp/whatsapp}" ) fi podman run "${args[@]}" localhost/xf/gia:prod sh -c "$cmd" >/dev/null } down_stack() { podman pod rm -f "$POD_NAME" >/dev/null 2>&1 || true rm_if_exists "$REDIS_CONTAINER" rm_if_exists "$SIGNAL_CONTAINER" rm_if_exists "$MIGRATION_CONTAINER" rm_if_exists "$COLLECTSTATIC_CONTAINER" rm_if_exists "$APP_CONTAINER" rm_if_exists "$ASGI_CONTAINER" rm_if_exists "$UR_CONTAINER" rm_if_exists "$SCHED_CONTAINER" rm_if_exists "$CODEX_WORKER_CONTAINER" rm_if_exists "$PROSODY_CONTAINER" } start_stack() { require_podman load_env REPO_DIR="$(resolve_path "$REPO_DIR")" APP_DATABASE_FILE="$(resolve_path "$APP_DATABASE_FILE")" APP_DATABASE_BASENAME="$(basename "$APP_DATABASE_FILE")" APP_DATABASE_PATH="/conf/$APP_DATABASE_BASENAME" HOST_DATABASE_FILE="$SQLITE_DATA_DIR/$APP_DATABASE_BASENAME" APP_LOCAL_SETTINGS="$(resolve_path "$APP_LOCAL_SETTINGS")" ensure_dirs if [[ "$APP_DATABASE_FILE" != "$HOST_DATABASE_FILE" ]] && [[ -f "$APP_DATABASE_FILE" ]] && [[ ! -f "$HOST_DATABASE_FILE" ]]; then cp "$APP_DATABASE_FILE" "$HOST_DATABASE_FILE" fi touch "$HOST_DATABASE_FILE" chmod 0666 "$HOST_DATABASE_FILE" 2>/dev/null || true down_stack local port_offset="${GIA_STACK_PORT_OFFSET:-}" if [[ -z "$port_offset" && -n "$STACK_ID" ]]; then port_offset="$(( $(printf '%s' "$STACK_ID" | cksum | awk '{print $1}') % 500 + 1 ))" fi port_offset="${port_offset:-0}" local app_port="${APP_PORT:-$((5006 + port_offset))}" local signal_port="${SIGNAL_PUBLIC_PORT:-$((8080 + port_offset))}" local prosody_c2s_port="${PROSODY_PUBLIC_C2S_PORT:-5222}" local prosody_s2s_port="${PROSODY_PUBLIC_S2S_PORT:-5269}" local prosody_component_port="${PROSODY_PUBLIC_COMPONENT_PORT:-8888}" local prosody_http_port="${PROSODY_PUBLIC_HTTP_PORT:-5280}" local pod_args=(--name "$POD_NAME" -p "${app_port}:8000" -p "${signal_port}:8080") if [[ "$PROSODY_ENABLED" == "true" ]]; then pod_args+=( -p "${prosody_c2s_port}:5222" -p "${prosody_s2s_port}:5269" -p "${prosody_component_port}:8888" -p "${prosody_http_port}:5280" ) fi podman pod create "${pod_args[@]}" >/dev/null podman run -d \ --replace \ --name "$REDIS_CONTAINER" \ --pod "$POD_NAME" \ -v "$REPO_DIR/docker/redis.conf:/etc/redis.conf:ro" \ -v "$REDIS_DATA_DIR:/data" \ -v "$VRUN_DIR:/var/run" \ docker.io/library/redis:latest \ redis-server /etc/redis.conf >/dev/null podman run -d \ --replace \ --name "$SIGNAL_CONTAINER" \ --pod "$POD_NAME" \ -e MODE=json-rpc \ -v "$ROOT_DIR/signal-cli-config:/home/.local/share/signal-cli" \ docker.io/bbernhard/signal-cli-rest-api:latest >/dev/null if [[ "$PROSODY_ENABLED" == "true" ]]; then podman run -d \ --replace \ --name "$PROSODY_CONTAINER" \ --pod "$POD_NAME" \ --env-file "$STACK_ENV" \ -v "$PROSODY_CONFIG_FILE:/etc/prosody/prosody.cfg.lua:ro" \ -v "$PROSODY_CERTS_DIR:/etc/prosody/certs" \ -v "$PROSODY_DATA_DIR:/var/lib/prosody" \ -v "$PROSODY_LOGS_DIR:/var/log/prosody" \ -v "$REPO_DIR:/code" \ docker.io/prosody/prosody:0.12 >/dev/null fi wait_for_redis_socket run_oneshot_container "$MIGRATION_CONTAINER" ". /venv/bin/activate && python manage.py migrate --noinput" run_oneshot_container "$COLLECTSTATIC_CONTAINER" ". /venv/bin/activate && python manage.py collectstatic --noinput" run_worker_container "$APP_CONTAINER" "if [ \"\$OPERATION\" = \"uwsgi\" ] ; then . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini ; else . /venv/bin/activate && exec python manage.py runserver 0.0.0.0:8000; fi" 1 1 run_worker_container "$ASGI_CONTAINER" "rm -f /var/run/asgi-gia.sock && . /venv/bin/activate && python -m pip install --disable-pip-version-check -q uvicorn && python -m uvicorn app.asgi:application --uds /var/run/asgi-gia.sock --workers 1" 0 1 run_worker_container "$UR_CONTAINER" ". /venv/bin/activate && python manage.py ur" 1 1 run_worker_container "$SCHED_CONTAINER" ". /venv/bin/activate && python manage.py scheduling" 1 0 run_worker_container "$CODEX_WORKER_CONTAINER" ". /venv/bin/activate && python manage.py codex_worker" 1 0 } render_units() { python3 "$ROOT_DIR/scripts/quadlet/render_units.py" --stack-env "$STACK_ENV" } case "${1:-}" in install) render_units ;; up) start_stack trap 'down_stack; exit 0' INT TERM if is_remote; then podman logs -f "$APP_CONTAINER" || true else podman logs -f "$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER" || true fi ;; down) require_podman down_stack ;; restart) start_stack ;; status) require_podman load_env podman pod ps --format "table {{.Name}}\t{{.Status}}" | grep -E "^$POD_NAME\b" || true podman ps --format "table {{.Names}}\t{{.Status}}" | grep -E "^($APP_CONTAINER|$ASGI_CONTAINER|$UR_CONTAINER|$SCHED_CONTAINER|$CODEX_WORKER_CONTAINER|$REDIS_CONTAINER|$SIGNAL_CONTAINER|$PROSODY_CONTAINER)\b" || true ;; logs) require_podman load_env if is_remote; then podman logs -f "$APP_CONTAINER" else local log_targets=("$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$CODEX_WORKER_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER") if [[ "$PROSODY_ENABLED" == "true" ]]; then log_targets+=("$PROSODY_CONTAINER") fi podman logs -f "${log_targets[@]}" fi ;; watch) require_podman load_env exec "$ROOT_DIR/scripts/quadlet/watchdog.sh" ;; *) echo "Usage: $0 {install|up|down|restart|status|logs|watch}" >&2 exit 2 ;; esac