furtka/furtka/api.py
Daniel Maksymilian Syrnicki e6f52ada5c feat(furtka): per-app image updates via POST /api/apps/<name>/update
Phase 1 of updates. User clicks Update on an installed app row →
the resource manager runs `docker compose pull`, compares the
running container's image ID to the just-pulled local image ID
per service, and only runs `docker compose up -d` if something
actually changed. Response is {updated: bool, services: [{service,
from, to, tag}]} so the UI can tell the user what happened.

Deliberately small: no pinning, no background checks, no "update
all" button, no version/changelog display. The update flow doesn't
mutate the compose file — it just acts on what's already there.
Reinstall still serves as rollback.

New dockerops helpers: compose_pull, compose_image_tags (parses
`docker compose config --format json`), local_image_id (via
`docker image inspect`), running_container_image_id (via compose
ps --quiet + docker inspect). Six new tests cover the endpoint:
not installed, no changes, changes applied, service not running,
docker pull error, and the HTTP route end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:45:47 +02:00

622 lines
23 KiB
Python

# ruff: noqa: E501 — _HTML below is a literal HTML/CSS/JS payload; wrapping
# its lines hurts readability and the rendered output is what matters here.
"""Tiny HTTP API + management UI for the Furtka resource manager.
Single stdlib http.server process, no Flask/no third-party deps so we don't
have to pip-install anything on the target. Caddy reverse-proxies /apps and
/api from :80 to here.
Security: NO AUTH. Bound to 127.0.0.1 by default; the Caddy proxy makes it
LAN-reachable. Anyone on the LAN can install/remove apps. The UI shouts this
out at the top. Auth lands when Authentik does.
"""
import json
import re
from http.server import BaseHTTPRequestHandler, HTTPServer
from furtka import dockerops, installer, reconciler
from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir, bundled_apps_dir
from furtka.scanner import scan
_ICON_MAX_BYTES = 16 * 1024
_UNSAFE_SVG_PATTERNS = (
re.compile(r"<script", re.IGNORECASE),
re.compile(r"javascript:", re.IGNORECASE),
re.compile(r"\bon[a-z]+\s*=", re.IGNORECASE),
)
def _read_icon_svg(app_dir, icon_name):
"""Return an SVG string safe to inline into the /apps response, or None.
Inlined rather than served via a separate endpoint so the /apps page
renders in one round-trip (Doherty Threshold). The trust model: icons
come from bundled apps we ship in the ISO or from apps the operator
installed via the (auth-less-by-design) API — so the realistic threat
is a malformed file, not an attacker. Filter the obvious script /
event-handler vectors for defense in depth and let the browser render
the rest.
"""
if not app_dir or not icon_name:
return None
path = app_dir / icon_name
try:
if not path.is_file() or path.stat().st_size > _ICON_MAX_BYTES:
return None
data = path.read_text(encoding="utf-8")
except OSError:
return None
data = data.strip()
if data.startswith("<?xml"):
end = data.find("?>")
if end == -1:
return None
data = data[end + 2 :].lstrip()
if not data.startswith("<svg"):
return None
if any(p.search(data) for p in _UNSAFE_SVG_PATTERNS):
return None
return data
_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka Apps</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="wrap">
<nav class="nav">
<a class="brand" href="/">Furtka</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/apps" aria-current="page">Apps</a>
<a href="/settings/">Settings</a>
</div>
</nav>
<h1>Furtka Apps</h1>
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
<div class="warn">No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.</div>
<h2>Installed</h2>
<div id="installed"></div>
<h2>Available to install</h2>
<div id="available"></div>
<details class="log-details">
<summary>Last action</summary>
<pre id="log">(none yet)</pre>
</details>
</main>
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
<div class="modal">
<h3 id="modal-title"></h3>
<div id="modal-long" class="long"></div>
<div id="modal-error" class="error"></div>
<form id="modal-form" onsubmit="return false;"></form>
<div class="modal-actions">
<button type="button" class="secondary" id="modal-cancel">Cancel</button>
<button type="button" id="modal-submit">Install</button>
</div>
</div>
</div>
<script>
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
// Fallback when an app doesn't ship a parseable icon.svg. Simple
// stroked folder — currentColor so the tile's accent tint applies.
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
function appIcon(a) {
// `a.icon_svg` is already sanitized server-side (see _read_icon_svg).
return `<div class="app-icon">${a.icon_svg || FALLBACK_ICON}</div>`;
}
const modal = {
backdrop: document.getElementById('modal-backdrop'),
title: document.getElementById('modal-title'),
long: document.getElementById('modal-long'),
form: document.getElementById('modal-form'),
error: document.getElementById('modal-error'),
submit: document.getElementById('modal-submit'),
cancel: document.getElementById('modal-cancel'),
current: null, // { name, action: 'install' | 'edit' }
};
modal.cancel.addEventListener('click', () => closeModal());
modal.backdrop.addEventListener('click', (e) => { if (e.target === modal.backdrop) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
function closeModal() {
modal.backdrop.classList.remove('open');
modal.form.innerHTML = '';
modal.error.classList.remove('show');
modal.error.textContent = '';
modal.current = null;
}
async function openSettingsDialog(name, action) {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
if (!r.ok) {
document.getElementById('log').textContent =
`[settings ${name}] HTTP ${r.status}\\n` + await r.text();
return;
}
const data = await r.json();
modal.current = { name, action };
modal.title.textContent = data.display_name || data.name;
modal.long.textContent = data.description_long || data.description || '';
modal.long.style.display = modal.long.textContent ? '' : 'none';
modal.submit.textContent = action === 'install' ? 'Install' : 'Save and restart';
if (!data.settings.length) {
// No form fields — treat as simple confirm.
modal.form.innerHTML = '<p class="hint">No settings to configure.</p>';
} else {
modal.form.innerHTML = data.settings.map(s => {
const id = `field-${esc(s.name)}`;
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : '';
return `
<div class="field">
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
${s.description ? `<div class="hint">${esc(s.description)}</div>` : ''}
<input
id="${id}"
name="${esc(s.name)}"
type="${s.type === 'password' ? 'password' : s.type === 'number' ? 'number' : 'text'}"
value="${value}"
placeholder="${esc(placeholder)}"
${s.required && action === 'install' ? 'required' : ''}
autocomplete="off"
spellcheck="false">
</div>`;
}).join('');
}
modal.backdrop.classList.add('open');
const first = modal.form.querySelector('input');
if (first) first.focus();
}
modal.submit.addEventListener('click', submitModal);
async function submitModal() {
if (!modal.current) return;
const { name, action } = modal.current;
const values = {};
for (const input of modal.form.querySelectorAll('input')) {
// In edit mode, skip password fields left blank — server keeps existing.
if (action === 'edit' && input.type === 'password' && input.value === '') continue;
values[input.name] = input.value;
}
modal.submit.disabled = true;
const original = modal.submit.textContent;
modal.submit.textContent = action === 'install' ? 'Installing…' : 'Saving…';
modal.error.classList.remove('show');
try {
const url = action === 'install'
? '/api/apps/install'
: `/api/apps/${encodeURIComponent(name)}/settings`;
const body = action === 'install' ? { name, settings: values } : { settings: values };
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${action} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
if (!r.ok) {
modal.error.textContent = data.error || `HTTP ${r.status}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
return;
}
closeModal();
await refresh();
} catch (e) {
modal.error.textContent = `Network error: ${e.message}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
}
}
async function refresh() {
const [installed, available] = await Promise.all([
fetch('/api/apps').then(r => r.json()),
fetch('/api/bundled').then(r => r.json()),
]);
document.getElementById('installed').innerHTML = installed.length
? installed.map(a => {
const hasSettings = a.has_settings;
return `
<div class="app">
<div class="left">
${appIcon(a)}
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span>
</div>
</div>
<div class="buttons">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
</div>`;
}).join('')
: '<div class="empty">No apps installed yet.</div>';
document.getElementById('available').innerHTML = available.length
? available.map(a => `
<div class="app">
<div class="left">
${appIcon(a)}
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || '')}</span>
</div>
</div>
<button data-op="install" data-name="${esc(a.name)}">Install</button>
</div>`).join('')
: '<div class="empty">No bundled apps left to install.</div>';
for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
}
}
async function handleButton(op, name, btn) {
if (op === 'install' || op === 'edit') {
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
return;
}
// Reinstall + update + remove are direct actions, no form.
btn.disabled = true;
const original = btn.textContent;
const labels = { reinstall: 'Reinstalling…', update: 'Checking…', remove: 'Removing…' };
btn.textContent = labels[op] || 'Working…';
try {
const urls = {
reinstall: '/api/apps/install',
remove: '/api/apps/remove',
update: `/api/apps/${encodeURIComponent(name)}/update`,
};
const r = await fetch(urls[op], {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await r.json();
let header = `[${op} ${name}] HTTP ${r.status}`;
if (op === 'update' && r.ok) {
header += data.updated
? ` — updated ${data.services.length} service(s)`
: ' — already up to date';
}
document.getElementById('log').textContent = header + '\\n' + JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById('log').textContent = `[${op} ${name}] network error: ${e.message}`;
}
btn.textContent = original;
await refresh();
}
refresh();
</script>
</body>
</html>
"""
def _manifest_summary(m, app_dir=None):
return {
"name": m.name,
"display_name": m.display_name,
"version": m.version,
"description": m.description,
"description_long": m.description_long,
"ports": list(m.ports),
"icon": m.icon,
"icon_svg": _read_icon_svg(app_dir, m.icon),
"has_settings": bool(m.settings),
}
def _list_installed():
out = []
for r in scan(apps_dir()):
if r.ok:
d = _manifest_summary(r.manifest, r.path)
d["ok"] = True
out.append(d)
else:
out.append({"name": r.path.name, "ok": False, "error": r.error})
return out
def _list_bundled():
installed_names = {r.path.name for r in scan(apps_dir()) if r.ok}
bundled = bundled_apps_dir()
if not bundled.exists():
return []
out = []
for entry in sorted(bundled.iterdir()):
if not entry.is_dir() or entry.name in installed_names:
continue
manifest_path = entry / "manifest.json"
if not manifest_path.exists():
continue
try:
m = load_manifest(manifest_path)
except ManifestError:
continue
out.append(_manifest_summary(m, entry))
return out
def _load_manifest_for(name):
"""Return (manifest, env_values, installed_bool) for an installed or bundled app.
Returns (None, None, False) if the name doesn't resolve anywhere.
"""
target = apps_dir() / name
if target.exists() and (target / "manifest.json").exists():
try:
m = load_manifest(target / "manifest.json")
except ManifestError:
return None, None, False
values = installer.read_env_values(target / ".env")
return m, values, True
bundled = bundled_apps_dir() / name
if bundled.exists() and (bundled / "manifest.json").exists():
try:
m = load_manifest(bundled / "manifest.json")
except ManifestError:
return None, None, False
env_example = bundled / ".env.example"
values = installer.read_env_values(env_example) if env_example.exists() else {}
return m, values, False
return None, None, False
def _do_get_settings(name):
m, values, installed = _load_manifest_for(name)
if m is None:
return 404, {"error": f"{name!r} not found"}
settings_out = []
for s in m.settings:
# Never return password values back to the client — user either keeps
# the current value (blank input means "don't change") or types a new one.
if s.type == "password":
current = ""
else:
current = values.get(s.name, s.default if s.default is not None else "")
settings_out.append(
{
"name": s.name,
"label": s.label,
"description": s.description,
"type": s.type,
"required": s.required,
"default": s.default,
"value": current,
}
)
return 200, {
"name": m.name,
"display_name": m.display_name,
"description": m.description,
"description_long": m.description_long,
"installed": installed,
"settings": settings_out,
}
def _do_install(name, settings=None):
try:
src = installer.resolve_source(name)
target = installer.install_from(src, settings=settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
payload = {
"installed": str(target),
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
}
# 207 Multi-Status — install copy succeeded but reconcile had per-app errors.
return (207 if reconciler.has_errors(actions) else 200, payload)
def _do_update_settings(name, settings):
"""Write settings into an installed app's .env and kick off a reinstall.
Only works for already-installed apps — use /api/apps/install for fresh
installs (since bundled-app folders under /opt/... are read-only).
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
installer.update_env(name, settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
return (
207 if reconciler.has_errors(actions) else 200,
{
"updated": name,
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
},
)
def _do_remove(name):
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
compose_warning = None
try:
dockerops.compose_down(target, name)
except dockerops.DockerError as e:
compose_warning = str(e)
try:
installer.remove(name)
except installer.InstallError as e:
return 500, {"error": str(e)}
return 200, {"removed": name, "compose_warning": compose_warning}
def _do_update(name):
"""Pull newer container images for an installed app; restart if any changed.
Behaviour:
- 404 if the app isn't installed.
- Always runs `docker compose pull` first (cheap no-op when nothing to
fetch, touches the network).
- For each service, compares the container's running image ID against
the post-pull local image ID. If a service's image advanced, runs
`docker compose up -d` so compose recreates the affected containers
in place.
- Returns {updated: bool, services: [{service, from, to}], ...}.
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
dockerops.compose_pull(target, name)
tags = dockerops.compose_image_tags(target, name)
changes = []
for service, tag in tags.items():
running = dockerops.running_container_image_id(target, name, service)
local = dockerops.local_image_id(tag)
if running and local and running != local:
changes.append({"service": service, "from": running, "to": local, "tag": tag})
if changes:
dockerops.compose_up(target, name)
except dockerops.DockerError as e:
return 502, {"error": str(e)}
return 200, {"app": name, "updated": bool(changes), "services": changes}
def _parse_settings_body(payload):
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
s = payload.get("settings")
if s is None:
return None
if not isinstance(s, dict):
return False # sentinel — caller should reject
out = {}
for k, v in s.items():
if not isinstance(k, str):
return False
out[k] = "" if v is None else str(v)
return out
class _Handler(BaseHTTPRequestHandler):
def _json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, status, body):
b = body.encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_GET(self): # noqa: N802 — http.server convention
if self.path in ("/", "/apps", "/apps/"):
return self._html(200, _HTML)
if self.path == "/api/apps":
return self._json(200, _list_installed())
if self.path == "/api/bundled":
return self._json(200, _list_bundled())
# /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name)
return self._json(status, body)
self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b""
try:
payload = json.loads(raw.decode()) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError):
return self._json(400, {"error": "invalid JSON body"})
if not isinstance(payload, dict):
return self._json(400, {"error": "body must be a JSON object"})
# Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload)
if settings is False or settings is None:
return self._json(400, {"error": "missing or invalid 'settings' object"})
status, body = _do_update_settings(name, settings)
return self._json(status, body)
# Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name)
return self._json(status, body)
name = payload.get("name")
if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"})
if self.path == "/api/apps/install":
settings = _parse_settings_body(payload)
if settings is False:
return self._json(400, {"error": "'settings' must be an object"})
status, body = _do_install(name, settings=settings)
elif self.path == "/api/apps/remove":
status, body = _do_remove(name)
else:
status, body = 404, {"error": "not found"}
self._json(status, body)
def log_message(self, fmt, *args): # noqa: A003
# Quiet — systemd journal already records the access via Caddy's log.
pass
def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
server = HTTPServer((host, port), _Handler)
print(f"Furtka API listening on http://{host}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()