Switch to Quadlet and add agent instructions

This commit is contained in:
2026-02-19 01:33:40 +00:00
parent c400c46e7d
commit bbb19f3c2c
6 changed files with 629 additions and 4 deletions

183
scripts/quadlet/manage.sh Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
STACK_ENV="${STACK_ENV:-$ROOT_DIR/stack.env}"
POD_NAME="gia"
REDIS_CONTAINER="redis_gia"
SIGNAL_CONTAINER="signal"
MIGRATION_CONTAINER="migration_gia"
COLLECTSTATIC_CONTAINER="collectstatic_gia"
APP_CONTAINER="gia"
ASGI_CONTAINER="asgi_gia"
UR_CONTAINER="ur_gia"
SCHED_CONTAINER="scheduling_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}"
VRUN_DIR="/code/vrun"
load_env() {
set -a
. "$STACK_ENV"
set +a
}
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" "$VRUN_DIR" "$ROOT_DIR/signal-cli-config"
}
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"
--env-file "$STACK_ENV"
--env "SIGNAL_HTTP_URL=http://127.0.0.1:8080"
-v "$REPO_DIR:/code"
-v "$APP_DATABASE_FILE:/conf/db.sqlite3"
-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"
--env-file "$STACK_ENV"
--env "SIGNAL_HTTP_URL=http://127.0.0.1:8080"
-v "$REPO_DIR:/code"
-v "$APP_DATABASE_FILE:/conf/db.sqlite3"
-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"
}
start_stack() {
require_podman
load_env
ensure_dirs
down_stack
podman pod create --name "$POD_NAME" -p "${APP_PORT:-5006}:8000" -p "8080:8080" >/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
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
}
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
podman logs -f "$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER" || true
;;
down)
require_podman
down_stack
;;
restart)
start_stack
;;
status)
require_podman
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|$REDIS_CONTAINER|$SIGNAL_CONTAINER)\b" || true
;;
logs)
require_podman
podman logs -f "$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER"
;;
*)
echo "Usage: $0 {install|up|down|restart|status|logs}" >&2
exit 2
;;
esac

251
scripts/quadlet/render_units.py Executable file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import argparse
import os
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)
repo_dir = abs_from(repo_root, env.get("REPO_DIR", "."), ".")
app_db_file = abs_from(repo_root, env.get("APP_DATABASE_FILE", "./db.sqlite3"), "./db.sqlite3")
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")
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, vrun_dir, signal_cli_dir):
p.mkdir(parents=True, exist_ok=True)
out_dir = Path(args.output_dir).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
env_file = stack_env_path
pod_unit = """
[Unit]
Description=GIA Pod
[Pod]
PodName=gia
[Install]
WantedBy=default.target
"""
target_unit = """
[Unit]
Description=GIA Stack Target
Wants=gia-redis.service gia-signal.service gia-migration.service gia-collectstatic.service gia-app.service gia-asgi.service gia-ur.service gia-scheduling.service
After=gia-redis.service gia-signal.service gia-migration.service gia-collectstatic.service
[Install]
WantedBy=default.target
"""
redis_unit = f"""
[Unit]
Description=GIA Redis
PartOf=gia.target
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/redis:latest
ContainerName=redis_gia
Pod=gia.pod
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=gia.target
"""
signal_unit = f"""
[Unit]
Description=GIA Signal API
PartOf=gia.target
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/bbernhard/signal-cli-rest-api:latest
ContainerName=signal
Pod=gia.pod
Volume={signal_cli_dir}:/home/.local/share/signal-cli
Environment=MODE=json-rpc
[Service]
Restart=always
RestartSec=2
[Install]
WantedBy=gia.target
"""
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}",
"PartOf=gia.target",
f"After={after}",
f"Requires={requires}",
"",
"[Container]",
"Image=localhost/xf/gia:prod",
f"ContainerName={container_name}",
"Pod=gia.pod",
f"EnvironmentFile={env_file}",
"Environment=SIGNAL_HTTP_URL=http://127.0.0.1:8080",
f"Volume={repo_dir}:/code",
f"Volume={app_db_file}:/conf/db.sqlite3",
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",
"ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do [ -S /code/vrun/gia-redis.sock ] && exit 0; sleep 1; done; exit 1'",
])
else:
lines.extend([
"Restart=always",
"RestartSec=2",
])
lines.extend(["", "[Install]", "WantedBy=gia.target"])
return "\n".join(lines)
migration_unit = gia_container(
"GIA Migration",
"migration_gia",
"sh -c '. /venv/bin/activate && python manage.py migrate --noinput'",
include_uwsgi=False,
include_whatsapp=False,
after="gia-redis.service gia-signal.service",
requires="gia-redis.service gia-signal.service",
one_shot=True,
)
collectstatic_unit = gia_container(
"GIA Collectstatic",
"collectstatic_gia",
"sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'",
include_uwsgi=False,
include_whatsapp=False,
after="gia-migration.service",
requires="gia-migration.service",
one_shot=True,
)
app_unit = gia_container(
"GIA App",
"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="gia-collectstatic.service gia-redis.service gia-signal.service",
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
)
asgi_unit = gia_container(
"GIA ASGI",
"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="gia-collectstatic.service gia-redis.service gia-signal.service",
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
)
ur_unit = gia_container(
"GIA Unified Router",
"ur_gia",
"sh -c '. /venv/bin/activate && python manage.py ur'",
include_uwsgi=True,
include_whatsapp=True,
after="gia-collectstatic.service gia-redis.service gia-signal.service",
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
)
scheduling_unit = gia_container(
"GIA Scheduling",
"scheduling_gia",
"sh -c '. /venv/bin/activate && python manage.py scheduling'",
include_uwsgi=True,
include_whatsapp=False,
after="gia-collectstatic.service gia-redis.service gia-signal.service",
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
)
write_unit(out_dir / "gia.pod", pod_unit)
write_unit(out_dir / "gia.target", target_unit)
write_unit(out_dir / "gia-redis.container", redis_unit)
write_unit(out_dir / "gia-signal.container", signal_unit)
write_unit(out_dir / "gia-migration.container", migration_unit)
write_unit(out_dir / "gia-collectstatic.container", collectstatic_unit)
write_unit(out_dir / "gia-app.container", app_unit)
write_unit(out_dir / "gia-asgi.container", asgi_unit)
write_unit(out_dir / "gia-ur.container", ur_unit)
write_unit(out_dir / "gia-scheduling.container", scheduling_unit)
print(f"Wrote Quadlet units to: {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())