Git-zentriertes guck-admin Deployment über Butler: app.py
This commit is contained in:
parent
4102dd6b83
commit
50a23dc780
1 changed files with 166 additions and 1 deletions
167
app.py
167
app.py
|
|
@ -1723,6 +1723,34 @@ SPEEDTEST_REPO_FILES = (
|
|||
"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):
|
||||
stats_password: str
|
||||
|
|
@ -1730,7 +1758,7 @@ class SpeedtestDeployRequest(BaseModel):
|
|||
|
||||
|
||||
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")
|
||||
cfg = SERVICES.get("forgejo", {})
|
||||
base_url = cfg.get("url")
|
||||
|
|
@ -1834,6 +1862,143 @@ async def vps_speedtest_deploy(req: SpeedtestDeployRequest, _=Depends(_verify)):
|
|||
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 ---
|
||||
import subprocess as _sp
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue