305 lines
11 KiB
Python
Executable File
305 lines
11 KiB
Python
Executable File
#!/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://127.0.0.1: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())
|