Git-zentriertes guck-admin Deployment über Butler #46

Merged
sascha merged 4 commits from feat/guck-admin-deploy-20260831 into main 2026-08-31 09:49:36 +02:00
Showing only changes of commit 50a23dc780 - Show all commits

167
app.py
View file

@ -1723,6 +1723,34 @@ SPEEDTEST_REPO_FILES = (
"streamscope/static/assets/longterm-metrics.js", "streamscope/static/assets/longterm-metrics.js",
) )
GUCK_ADMIN_REPO_FILES = (
"guck-admin/Dockerfile",
"guck-admin/compose.yaml",
"guck-admin/requirements.txt",
"guck-admin/src/app.py",
"guck-admin/src/control.py",
"guck-admin/src/sharing_watchdog.py",
"guck-admin/src/templates/bandwidth.html",
"guck-admin/src/templates/base.html",
"guck-admin/src/templates/dashboard.html",
"guck-admin/src/templates/history.html",
"guck-admin/src/templates/sessions.html",
"guck-admin/src/templates/settings.html",
"guck-admin/src/templates/sharing.html",
"guck-admin/src/templates/users.html",
"guck-admin/static/admin.css",
"guck-admin/static/admin.js",
"guck-admin/static/icon.svg",
"guck-admin/static/manifest.webmanifest",
"guck-admin/static/sw.js",
"guck-admin/static/world.svg",
)
FORGEJO_DEPLOY_FILES = {
"sascha/speedtest": frozenset(SPEEDTEST_REPO_FILES),
"sascha/guck-vps": frozenset(GUCK_ADMIN_REPO_FILES),
}
class SpeedtestDeployRequest(BaseModel): class SpeedtestDeployRequest(BaseModel):
stats_password: str stats_password: str
@ -1730,7 +1758,7 @@ class SpeedtestDeployRequest(BaseModel):
async def _fetch_forgejo_text(repo: str, path: str) -> str: async def _fetch_forgejo_text(repo: str, path: str) -> str:
if repo != "sascha/speedtest" or path not in SPEEDTEST_REPO_FILES: if path not in FORGEJO_DEPLOY_FILES.get(repo, frozenset()):
raise ValueError("unsupported Forgejo file") raise ValueError("unsupported Forgejo file")
cfg = SERVICES.get("forgejo", {}) cfg = SERVICES.get("forgejo", {})
base_url = cfg.get("url") base_url = cfg.get("url")
@ -1834,6 +1862,143 @@ async def vps_speedtest_deploy(req: SpeedtestDeployRequest, _=Depends(_verify)):
return result return result
class GuckAdminDeployRequest(BaseModel):
dry_run: bool = True
def _validate_guck_admin_bundle(files: dict[str, str]) -> None:
if set(files) != set(GUCK_ADMIN_REPO_FILES):
raise ValueError("guck-admin source bundle is incomplete")
compose = files["guck-admin/compose.yaml"]
control = files["guck-admin/src/control.py"]
compose_required = (
"network_mode: host",
"NET_ADMIN",
"/app-config/guck-admin/data:/data",
"GUCK_LIMIT: /host/guck-limit.sh",
)
if any(item not in compose for item in compose_required):
raise ValueError("guck-admin compose is missing a required security or persistence setting")
policy_required = (
"CREATE TABLE IF NOT EXISTS custom_networks",
"def sync_custom_networks",
"2a00:8c40:f000::/36",
"45.58.235.0/24",
)
if any(item not in control for item in policy_required):
raise ValueError("guck-admin custom VPN policy is incomplete")
def _guck_admin_remote_python(target: str, script: str, timeout: int = 60):
encoded = base64.b64encode(script.encode()).decode()
command = f"sudo -n python3 -c \"import base64;exec(base64.b64decode('{encoded}'))\""
return _ssh(target, command, timeout=timeout)
def _deploy_guck_admin_compose(files: dict[str, str], dry_run: bool = True) -> dict:
_validate_guck_admin_bundle(files)
inventory = _find_inventory_host("guck-vps")
if not inventory:
raise RuntimeError("guck-vps is missing from Butler inventory")
target = f'{inventory["user"]}@{inventory["ip"]}'
if dry_run:
return {
"status": "validated",
"dry_run": True,
"host": "guck-vps",
"files": len(files),
"policy_networks": ["Mozilla Firefox VPN IPv6", "Fastly VPN IPv4"],
}
relative_files = {path.removeprefix("guck-admin/"): content for path, content in files.items()}
deploy_script = f"""from pathlib import Path
import os, shutil
stack = Path('/app-config/guck-admin')
backup = Path('/app-config/deployment-backups/guck-admin-rollback')
files = {relative_files!r}
if backup.exists():
shutil.rmtree(backup)
backup.mkdir(parents=True, exist_ok=True)
stack.mkdir(parents=True, exist_ok=True)
for relative, content in files.items():
target = stack / relative
old = backup / relative
if target.exists():
old.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(target, old)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(target.name + '.butler-new')
temporary.write_text(content)
os.replace(temporary, target)
"""
rollback_script = f"""from pathlib import Path
import os, shutil
stack = Path('/app-config/guck-admin')
backup = Path('/app-config/deployment-backups/guck-admin-rollback')
files = {tuple(relative_files)!r}
for relative in files:
target = stack / relative
old = backup / relative
if old.exists():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(old, target)
elif target.exists():
target.unlink()
"""
rc, _out, err = _guck_admin_remote_python(target, deploy_script, timeout=90)
if rc != 0:
raise RuntimeError(f"guck-admin file deployment failed: {err[-300:]}")
def rollback():
_guck_admin_remote_python(target, rollback_script, timeout=90)
_ssh(target, "cd /app-config/guck-admin && sudo -n docker compose up -d --build --remove-orphans", timeout=600)
preflight = "cd /app-config/guck-admin && sudo -n docker compose config -q && sudo -n docker compose build --pull"
rc, _out, err = _ssh(target, preflight, timeout=600)
if rc != 0:
rollback()
raise RuntimeError(f"guck-admin build preflight failed: {err[-500:]}")
deploy = "cd /app-config/guck-admin && sudo -n docker compose up -d --remove-orphans"
rc, out, err = _ssh(target, deploy, timeout=240)
if rc != 0:
rollback()
raise RuntimeError(f"guck-admin deployment failed: {(err or out)[-500:]}")
health = "for i in $(seq 1 45); do curl -fsS --max-time 3 http://127.0.0.1:9090/health >/dev/null && exit 0; sleep 2; done; exit 1"
rc, _out, err = _ssh(target, health, timeout=105)
if rc != 0:
rollback()
raise RuntimeError(f"guck-admin health check failed and rollback was attempted: {err[-300:]}")
policy = "curl -fsS -X POST --max-time 120 http://127.0.0.1:9090/actions/limiter/refresh >/dev/null && sudo -n ipset test vpn-v6 2a00:8c40:f02d:a34c::1 && sudo -n ipset test vpn-v4 45.58.235.7 && sudo -n tc class show dev ens3 | python3 -c \"import sys; s=sys.stdin.read(); raise SystemExit(0 if '1:300' in s else 1)\""
rc, out, err = _ssh(target, policy, timeout=180)
if rc != 0:
rollback()
raise RuntimeError(f"guck-admin policy verification failed and rollback was attempted: {(err or out)[-500:]}")
return {
"status": "deployed",
"health": "ok",
"policy": "verified",
"host": "guck-vps",
"custom_networks": ["2a00:8c40:f000::/36", "45.58.235.0/24"],
"ipv4": True,
"ipv6": True,
}
@app.post("/vps/guck-admin/deploy")
async def vps_guck_admin_deploy(req: GuckAdminDeployRequest, _=Depends(_verify)):
try:
contents = await asyncio.gather(*(
_fetch_forgejo_text("sascha/guck-vps", path) for path in GUCK_ADMIN_REPO_FILES
))
files = dict(zip(GUCK_ADMIN_REPO_FILES, contents))
result = await asyncio.to_thread(_deploy_guck_admin_compose, files, req.dry_run)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
raise HTTPException(502, f"guck-admin deployment failed: {str(exc)[-500:]}") from exc
_audit("/vps/guck-admin/deploy", "POST", 200, f"dry_run={req.dry_run}; Git-managed custom VPN policy")
return result
# --- VM Lifecycle Endpoints --- # --- VM Lifecycle Endpoints ---
import subprocess as _sp import subprocess as _sp