#!/usr/bin/env python3 from __future__ import annotations from pathlib import Path import argparse import os import shutil def parse_env(path: Path) -> dict[str, str]: out: dict[str, str] = {} if not path.exists(): return out for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: continue key, value = line.split("=", 1) out[key.strip()] = value.strip().strip('"').strip("'") return out def abs_from(base: Path, raw: str, fallback: str) -> Path: candidate = (raw or fallback).strip() if not candidate: candidate = fallback p = Path(candidate).expanduser() if not p.is_absolute(): p = (base / p).resolve() return p def write_unit(path: Path, content: str) -> None: path.write_text(content.strip() + "\n") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--stack-env", default="stack.env") parser.add_argument("--output-dir", default=str(Path.home() / ".config/containers/systemd")) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[2] stack_env_path = abs_from(repo_root, args.stack_env, "stack.env") env = parse_env(stack_env_path) stack_id = str(env.get("GIA_STACK_ID") or env.get("STACK_ID") or "").strip() stack_id = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in stack_id).strip("-") def with_stack(base: str) -> str: return f"{base}_{stack_id}" if stack_id else base unit_prefix = f"gia-{stack_id}" if stack_id else "gia" pod_ref = f"{unit_prefix}.pod" target_ref = f"{unit_prefix}.target" stack_offset_raw = str(env.get("GIA_STACK_PORT_OFFSET") or "").strip() if stack_offset_raw: try: stack_port_offset = max(0, int(stack_offset_raw)) except Exception: stack_port_offset = 0 elif stack_id: stack_port_offset = (sum(ord(ch) for ch in stack_id) % 500) + 1 else: stack_port_offset = 0 app_port = int(env.get("APP_PORT") or (5006 + stack_port_offset)) signal_public_port = int(env.get("SIGNAL_PUBLIC_PORT") or (8080 + stack_port_offset)) repo_dir = abs_from(repo_root, env.get("REPO_DIR", "."), ".") host_uid = int(os.getuid()) host_gid = int(os.getgid()) app_db_file = abs_from(repo_root, env.get("APP_DATABASE_FILE", "./db.sqlite3"), "./db.sqlite3") app_db_basename = app_db_file.name sqlite_data_dir = abs_from( repo_root, env.get("QUADLET_SQLITE_DATA_DIR", "./.podman/gia_sqlite_data"), "./.podman/gia_sqlite_data", ) host_db_file = (sqlite_data_dir / app_db_basename).resolve() app_db_path_in_container = f"/conf/{app_db_basename}" redis_data_dir = abs_from(repo_root, env.get("QUADLET_REDIS_DATA_DIR", "./.podman/gia_redis_data"), "./.podman/gia_redis_data") whatsapp_data_dir = abs_from(repo_root, env.get("QUADLET_WHATSAPP_DATA_DIR", "./.podman/gia_whatsapp_data"), "./.podman/gia_whatsapp_data") vrun_dir = Path("/code/vrun") / stack_id if stack_id else Path("/code/vrun") signal_cli_dir = (repo_dir / "signal-cli-config").resolve() uwsgi_ini = (repo_dir / "docker" / "uwsgi.ini").resolve() redis_conf = (repo_dir / "docker" / "redis.conf").resolve() for p in (redis_data_dir, whatsapp_data_dir, sqlite_data_dir, vrun_dir, signal_cli_dir): p.mkdir(parents=True, exist_ok=True) sqlite_data_dir.chmod(0o777) if app_db_file.resolve() != host_db_file and app_db_file.exists() and not host_db_file.exists(): shutil.copy2(app_db_file, host_db_file) host_db_file.touch(exist_ok=True) host_db_file.chmod(0o666) out_dir = Path(args.output_dir).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) env_file = stack_env_path pod_unit = f""" [Unit] Description=GIA Pod [Pod] PodName={with_stack('gia')} PublishPort={app_port}:8000 PublishPort={signal_public_port}:8080 [Install] WantedBy=default.target """ target_unit = f""" [Unit] Description=GIA Stack Target Wants={unit_prefix}-redis.service {unit_prefix}-signal.service {unit_prefix}-migration.service {unit_prefix}-collectstatic.service {unit_prefix}-app.service {unit_prefix}-asgi.service {unit_prefix}-ur.service {unit_prefix}-scheduling.service {unit_prefix}-codex-worker.service After={unit_prefix}-redis.service {unit_prefix}-signal.service {unit_prefix}-migration.service {unit_prefix}-collectstatic.service [Install] WantedBy=default.target """ redis_unit = f""" [Unit] Description=GIA Redis PartOf={target_ref} After=network-online.target Wants=network-online.target [Container] Image=docker.io/library/redis:latest ContainerName={with_stack('redis_gia')} Pod={pod_ref} Volume={redis_conf}:/etc/redis.conf:ro Volume={redis_data_dir}:/data Volume={vrun_dir}:/var/run Exec=redis-server /etc/redis.conf [Service] Restart=always RestartSec=2 [Install] WantedBy={target_ref} """ signal_unit = f""" [Unit] Description=GIA Signal API PartOf={target_ref} After=network-online.target Wants=network-online.target [Container] Image=docker.io/bbernhard/signal-cli-rest-api:latest ContainerName={with_stack('signal')} Pod={pod_ref} Volume={signal_cli_dir}:/home/.local/share/signal-cli Environment=MODE=json-rpc [Service] Restart=always RestartSec=2 [Install] WantedBy={target_ref} """ def gia_container(name: str, container_name: str, command: str, include_uwsgi: bool, include_whatsapp: bool, after: str, requires: str, one_shot: bool = False) -> str: lines = [ "[Unit]", f"Description={name}", f"PartOf={target_ref}", f"After={after}", f"Requires={requires}", "", "[Container]", "Image=localhost/xf/gia:prod", f"ContainerName={container_name}", f"Pod={pod_ref}", f"User={host_uid}:{host_gid}", f"EnvironmentFile={env_file}", "Environment=SIGNAL_HTTP_URL=http://localhost:8080", f"Environment=APP_DATABASE_PATH={app_db_path_in_container}", f"Volume={repo_dir}:/code", f"Volume={sqlite_data_dir}:/conf", f"Volume={vrun_dir}:/var/run", ] if include_uwsgi: lines.append(f"Volume={uwsgi_ini}:/conf/uwsgi.ini") if include_whatsapp: lines.append(f"Volume={whatsapp_data_dir}:{env.get('WHATSAPP_DB_DIR', '/var/tmp/whatsapp')}") lines.append(f"Exec={command}") lines.extend(["", "[Service]"]) if one_shot: lines.extend([ "Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=0", f"ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do [ -S {vrun_dir}/gia-redis.sock ] && exit 0; sleep 1; done; exit 1'", ]) else: lines.extend([ "Restart=always", "RestartSec=2", ]) lines.extend(["", "[Install]", "WantedBy=gia.target"]) lines[-1] = f"WantedBy={target_ref}" return "\n".join(lines) migration_unit = gia_container( "GIA Migration", with_stack("migration_gia"), "sh -c '. /venv/bin/activate && python manage.py migrate --noinput'", include_uwsgi=False, include_whatsapp=False, after=f"{unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-redis.service {unit_prefix}-signal.service", one_shot=True, ) collectstatic_unit = gia_container( "GIA Collectstatic", with_stack("collectstatic_gia"), "sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'", include_uwsgi=False, include_whatsapp=False, after=f"{unit_prefix}-migration.service", requires=f"{unit_prefix}-migration.service", one_shot=True, ) app_unit = gia_container( "GIA App", with_stack("gia"), "sh -c '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'", include_uwsgi=True, include_whatsapp=True, after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", ) asgi_unit = gia_container( "GIA ASGI", with_stack("asgi_gia"), "sh -c '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'", include_uwsgi=False, include_whatsapp=True, after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", ) ur_unit = gia_container( "GIA Unified Router", with_stack("ur_gia"), "sh -c '. /venv/bin/activate && python manage.py ur'", include_uwsgi=True, include_whatsapp=True, after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", ) scheduling_unit = gia_container( "GIA Scheduling", with_stack("scheduling_gia"), "sh -c '. /venv/bin/activate && python manage.py scheduling'", include_uwsgi=True, include_whatsapp=False, after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", ) codex_worker_unit = gia_container( "GIA Codex Worker", with_stack("codex_worker_gia"), "sh -c '. /venv/bin/activate && python manage.py codex_worker'", include_uwsgi=True, include_whatsapp=False, after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service", ) write_unit(out_dir / f"{unit_prefix}.pod", pod_unit) write_unit(out_dir / f"{unit_prefix}.target", target_unit) write_unit(out_dir / f"{unit_prefix}-redis.container", redis_unit) write_unit(out_dir / f"{unit_prefix}-signal.container", signal_unit) write_unit(out_dir / f"{unit_prefix}-migration.container", migration_unit) write_unit(out_dir / f"{unit_prefix}-collectstatic.container", collectstatic_unit) write_unit(out_dir / f"{unit_prefix}-app.container", app_unit) write_unit(out_dir / f"{unit_prefix}-asgi.container", asgi_unit) write_unit(out_dir / f"{unit_prefix}-ur.container", ur_unit) write_unit(out_dir / f"{unit_prefix}-scheduling.container", scheduling_unit) write_unit(out_dir / f"{unit_prefix}-codex-worker.container", codex_worker_unit) print(f"Wrote Quadlet units to: {out_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())