Merge pull request 'Git-zentriertes guck-admin Deployment über Butler' (#46) from feat/guck-admin-deploy-20260831 into main

This commit is contained in:
sascha 2026-08-31 09:49:35 +02:00
commit 1318c826b6
3 changed files with 222 additions and 1 deletions

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

View file

@ -18,6 +18,8 @@ def load_app(monkeypatch):
def test_bw_manager_deploy_rejects_bundle_without_and_gate(monkeypatch): def test_bw_manager_deploy_rejects_bundle_without_and_gate(monkeypatch):
app = load_app(monkeypatch) app = load_app(monkeypatch)
if not hasattr(app, "BW_MANAGER_REPO_FILES"):
pytest.skip("BW-Manager was intentionally retired on 13.08.2026")
files = {path: "placeholder" for path in app.BW_MANAGER_REPO_FILES} files = {path: "placeholder" for path in app.BW_MANAGER_REPO_FILES}
files["compose.yaml"] = "services:\n bw-manager:\n build: ./src\n" files["compose.yaml"] = "services:\n bw-manager:\n build: ./src\n"
files["src/app.py"] = "def old_policy(): pass\n" files["src/app.py"] = "def old_policy(): pass\n"

View file

@ -0,0 +1,54 @@
import app
import pytest
from fastapi.testclient import TestClient
def valid_bundle():
files={path:'placeholder' for path in app.GUCK_ADMIN_REPO_FILES}
files['guck-admin/compose.yaml']='''services:\n guck-admin:\n build: .\n network_mode: host\n cap_add: [NET_ADMIN]\n environment:\n GUCK_LIMIT: /host/guck-limit.sh\n volumes:\n - /app-config/guck-admin/data:/data\n control-monitor:\n build: .\n network_mode: host\n cap_add: [NET_ADMIN]\n'''
files['guck-admin/src/control.py']='''CREATE TABLE IF NOT EXISTS custom_networks\n2a00:8c40:f000::/36\n45.58.235.0/24\ndef sync_custom_networks(): pass\n'''
return files
def test_guck_admin_bundle_requires_persistent_custom_network_policy():
files=valid_bundle()
files['guck-admin/src/control.py']='def old_control(): pass\n'
with pytest.raises(ValueError,match='custom VPN'):
app._validate_guck_admin_bundle(files)
def test_guck_admin_deploy_dry_run_has_no_remote_side_effect(monkeypatch):
calls=[]
monkeypatch.setattr(app,'_find_inventory_host',lambda host:{'user':'debian','ip':'141.94.237.199'})
monkeypatch.setattr(app,'_ssh',lambda *args,**kwargs:calls.append(args))
result=app._deploy_guck_admin_compose(valid_bundle(),dry_run=True)
assert result['status']=='validated'
assert result['host']=='guck-vps'
assert calls==[]
def test_guck_admin_deploy_uses_inventory_target_and_verifies_policy(monkeypatch):
calls=[]
monkeypatch.setattr(app,'_find_inventory_host',lambda host:{'user':'debian','ip':'141.94.237.199'})
def fake_ssh(host,command,timeout=600):
calls.append((host,command,timeout))
if 'ipset test vpn-v6' in command:
return 0,'policy ok',''
return 0,'ok',''
monkeypatch.setattr(app,'_ssh',fake_ssh)
result=app._deploy_guck_admin_compose(valid_bundle(),dry_run=False)
assert result['status']=='deployed'
assert result['policy']=='verified'
assert all(host=='debian@141.94.237.199' for host,_,_ in calls)
commands='\n'.join(command for _,command,_ in calls)
assert 'docker compose build' in commands
assert '127.0.0.1:9090/health' in commands
assert '/actions/limiter/refresh' in commands
assert 'ipset test vpn-v6 2a00:8c40:f02d:a34c::1' in commands
assert 'ipset test vpn-v4 45.58.235.7' in commands
def test_guck_admin_deploy_endpoint_requires_auth(monkeypatch):
monkeypatch.setattr(app,'BUTLER_TOKEN','test-token')
response=TestClient(app.app).post('/vps/guck-admin/deploy',json={'dry_run':True})
assert response.status_code in (401,403)