archy/image-recipe/archipelago-scripts/simple-api-server.py

133 lines
4.5 KiB
Python
Raw Normal View History

release(v1.7.41-alpha): post-OTA auto-rollback so a bad release cannot strand the fleet Closes failure mode FM5 from docs/bulletproof-containers.md: the v1.7.38 + v1.7.39 rollouts left every affected node on an unreachable UI (nginx 500) with no recovery path short of SSH. This release adds a self-check guardrail to the update flow. What changed: - apply_update() writes a pending-verify marker with old+new version and a 150s deadline immediately before scheduling the service restart. - verify_pending_update() runs from main.rs startup. If the marker is present and within its freshness window, the new binary waits 15s for nginx + backend to settle, then probes https://127.0.0.1/ every 5s for up to 90s (self-signed certs accepted). - On any probe success within the window, the marker is cleared and nothing else happens. - On window-exhaust, the new binary: 1. Moves the broken /opt/archipelago/web-ui to web-ui.failed.<ts> (quarantined, not deleted, so we can post-mortem). 2. Restores web-ui.bak on top of web-ui. 3. Calls rollback_update() to restore the previous binary. 4. Updates state.current_version to reflect the rollback. 5. systemctl --no-block restart archipelago so the OLD binary boots. - Markers older than 10 minutes are treated as stale and cleared without probing, so a crashed-during-startup marker from weeks ago cannot spontaneously roll back a healthy node on a later reboot. - rollback_update() binary copy now goes through host_sudo instead of tokio::fs::copy, so it escapes the service's ProtectSystem=strict mount namespace. Without this, the rollback silently failed with EROFS on /usr/local/bin and orphaned the rollback - the exact opposite of what auto-rollback is for. Tests: 4 new unit tests in update::tests covering marker round-trip, absent-marker noop, no-panic on verify_pending_update with nothing to verify, and an invariant assert that the 90s probe window stays below the 600s stale threshold. All passing. Side fix: scripts/create-release-manifest.sh was dying with exit 141 (SIGPIPE from tar tvzf pipe head pipe awk) under set -euo pipefail. Replaced with a single awk NR==1 that doesn't short-circuit the upstream pipe, so the release-build flow is idempotent again.
2026-04-22 16:14:35 -04:00
#!/usr/bin/env python3
"""
Simple API server for Archipelago Web UI
Serves static files and handles basic API endpoints
"""
import http.server
import socketserver
import json
import os
from urllib.parse import urlparse, parse_qs
PORT = 80
WEB_DIR = None
# Find web UI directory
for path in ['/opt/archipelago/web-ui', '/run/live/medium/archipelago/web-ui', '/lib/live/mount/medium/archipelago/web-ui']:
if os.path.isdir(path):
WEB_DIR = path
break
if not WEB_DIR:
print("Web UI directory not found!")
exit(1)
os.chdir(WEB_DIR)
class ArchipelagoHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
"""Handle POST requests with mock responses"""
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length) if content_length > 0 else b''
path = urlparse(self.path).path
# Mock API responses
response = {"success": True}
if '/api/auth' in path or '/auth' in path:
response = {
"success": True,
"token": "mock-token-12345",
"user": "archipelago"
}
elif '/api/setup' in path or '/setup' in path:
response = {
"success": True,
"status": "complete"
}
elif '/api/status' in path or '/status' in path:
response = {
"success": True,
"status": "running",
"version": "0.1.0",
"hostname": "archipelago"
}
elif '/api/apps' in path or '/apps' in path:
response = {
"success": True,
"apps": []
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
def do_OPTIONS(self):
"""Handle CORS preflight"""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.end_headers()
def do_GET(self):
"""Handle GET requests - serve files or API"""
path = urlparse(self.path).path
if path.startswith('/api/'):
# Mock API GET endpoints
response = {"success": True}
if '/status' in path:
response = {
"success": True,
"status": "running",
"version": "0.1.0"
}
elif '/apps' in path:
response = {
"success": True,
"apps": [
{"id": "bitcoin", "name": "Bitcoin Core", "status": "available"},
{"id": "lnd", "name": "Lightning (LND)", "status": "available"}
]
}
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(response).encode())
else:
# Serve static files, default to index.html for SPA routing
if not os.path.exists(self.translate_path(self.path)) or path == '/':
self.path = '/index.html'
super().do_GET()
def log_message(self, format, *args):
"""Quieter logging"""
if '404' in str(args) or 'error' in str(args).lower():
print(f" {args[0]}")
print(f"""
🏝 ARCHIPELAGO WEB UI
Serving from: {WEB_DIR}
🌐 Open in your browser: http://localhost:{PORT}
Press Ctrl+C to stop
""")
with socketserver.TCPServer(("0.0.0.0", PORT), ArchipelagoHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")