Merge pull request 'Git-zentriertes guck-admin Deployment über Butler' (#46) from feat/guck-admin-deploy-20260831 into main
This commit is contained in:
commit
1318c826b6
3 changed files with 222 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
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ def load_app(monkeypatch):
|
|||
|
||||
def test_bw_manager_deploy_rejects_bundle_without_and_gate(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["compose.yaml"] = "services:\n bw-manager:\n build: ./src\n"
|
||||
files["src/app.py"] = "def old_policy(): pass\n"
|
||||
|
|
|
|||
54
tests/test_guck_admin_deploy.py
Normal file
54
tests/test_guck_admin_deploy.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue