feat(furtka): web UI + HTTP API for app install/remove
Adds the management UI Daniel asked for end-of-session. Goes beyond
the original MVP scope (plan punted UI to v2) but the architecture
already supports it cleanly: stdlib http.server only, no new deps.
- furtka.api: minimal HTTP server. GET / serves a self-contained
HTML page (dark-mode card list, vanilla JS, no build step). GET
/api/apps + /api/bundled return JSON. POST /api/apps/{install,
remove} accept {"name": "..."} and call the same installer +
reconciler the CLI uses, so the placeholder-secret refusal and
per-app reconcile isolation flow through unchanged.
- furtka.cli: new `furtka serve` subcommand. Imports api lazily so
`furtka app list` / `reconcile` startup stays zero-cost.
- webinstaller: new furtka-api.service (Type=simple, restart on
failure, after reconcile). Caddyfile gets two new handle blocks
to reverse-proxy /api and /apps to localhost:7000. Landing page's
"App store coming soon" tile becomes a real "Manage installed apps
→" link to /apps.
- Bound to 127.0.0.1 by default; Caddy makes it LAN-reachable. The
UI shouts a "no auth, anyone on your LAN can install/remove" warning
at the top — Authentik integration is the proper fix later.
UX wrinkle worth noting: a placeholder-rejected install leaves the
app in /var/lib/furtka/apps/<name>/ (so the user can edit .env in
place). To re-trigger after editing, the Installed list now shows
both Reinstall and Remove buttons.
10 new tests: helper functions (list_installed, list_bundled with
hide-already-installed), install/remove endpoints with the no_docker
fixture, and two real-socket urllib smoke tests that boot the actual
HTTPServer on an ephemeral port and round-trip GET / + POST.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff68dd5ae6
commit
c6ed7a8159
5 changed files with 477 additions and 12 deletions
262
furtka/api.py
Normal file
262
furtka/api.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
# 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
|
||||
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
|
||||
|
||||
_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">
|
||||
<style>
|
||||
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; }
|
||||
* { box-sizing:border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; }
|
||||
h1 { font-size: 2rem; margin: 0; }
|
||||
h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin: 2rem 0 0.75rem; }
|
||||
.lede { color: var(--muted); margin: 0.25rem 0 1rem; }
|
||||
.warn { background: var(--warn); padding: 1rem; border-radius: 8px; margin: 1.5rem 0; color: #fed; font-size: 0.9rem; }
|
||||
.app { background: var(--card); padding: 1rem; border-radius: 8px; margin: 0.5rem 0; display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
|
||||
.meta { display: flex; flex-direction: column; min-width: 0; }
|
||||
.name { font-weight: 600; font-size: 1.05rem; }
|
||||
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
|
||||
.desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; }
|
||||
button.danger { background: var(--danger); }
|
||||
button:disabled { opacity: 0.5; cursor: wait; }
|
||||
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
|
||||
pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<h2>Last action</h2>
|
||||
<pre id="log">(none yet)</pre>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
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 => `
|
||||
<div class="app">
|
||||
<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>
|
||||
<button data-op="install" 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="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || '')}</span>
|
||||
</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', () => act(btn.dataset.op, btn.dataset.name, btn));
|
||||
}
|
||||
}
|
||||
|
||||
async function act(op, name, btn) {
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = op === 'install' ? 'Installing…' : 'Removing…';
|
||||
try {
|
||||
const r = await fetch(`/api/apps/${op}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name}),
|
||||
});
|
||||
const data = await r.json();
|
||||
document.getElementById('log').textContent =
|
||||
`[${op} ${name}] HTTP ${r.status}\\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):
|
||||
return {
|
||||
"name": m.name,
|
||||
"display_name": m.display_name,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"ports": list(m.ports),
|
||||
"icon": m.icon,
|
||||
}
|
||||
|
||||
|
||||
def _list_installed():
|
||||
out = []
|
||||
for r in scan(apps_dir()):
|
||||
if r.ok:
|
||||
d = _manifest_summary(r.manifest)
|
||||
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))
|
||||
return out
|
||||
|
||||
|
||||
def _do_install(name):
|
||||
try:
|
||||
src = installer.resolve_source(name)
|
||||
target = installer.install_from(src)
|
||||
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_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}
|
||||
|
||||
|
||||
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())
|
||||
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"})
|
||||
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":
|
||||
status, body = _do_install(name)
|
||||
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()
|
||||
|
|
@ -77,6 +77,15 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_serve(args: argparse.Namespace) -> int:
|
||||
# Imported lazily so `furtka` startup stays cheap when the user only runs
|
||||
# `app list` or `reconcile` (the common case during tests + scripts).
|
||||
from furtka import api
|
||||
|
||||
api.serve(args.host, args.port)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
||||
actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run)
|
||||
print(f"Scanned {apps_dir()}: {len(actions)} actions")
|
||||
|
|
@ -126,6 +135,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
reconcile.set_defaults(func=_cmd_reconcile)
|
||||
|
||||
serve = sub.add_parser("serve", help="Run the resource-manager HTTP API + UI")
|
||||
serve.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
|
||||
serve.add_argument("--port", type=int, default=7000, help="Bind port (default 7000)")
|
||||
serve.set_defaults(func=_cmd_serve)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
|
|
|
|||
155
tests/test_api.py
Normal file
155
tests/test_api.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from furtka import api, dockerops
|
||||
|
||||
VALID_MANIFEST = {
|
||||
"name": "fileshare",
|
||||
"display_name": "Network Files",
|
||||
"version": "0.1.0",
|
||||
"description": "SMB share",
|
||||
"volumes": ["files"],
|
||||
"ports": [445],
|
||||
"icon": "icon.svg",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_dirs(tmp_path, monkeypatch):
|
||||
apps = tmp_path / "apps"
|
||||
bundled = tmp_path / "bundled"
|
||||
apps.mkdir()
|
||||
bundled.mkdir()
|
||||
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
||||
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
||||
return apps, bundled
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_docker(monkeypatch):
|
||||
"""Stub docker calls so install/remove can run without a daemon."""
|
||||
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: True)
|
||||
monkeypatch.setattr(dockerops, "compose_up", lambda app_dir, project: None)
|
||||
monkeypatch.setattr(dockerops, "compose_down", lambda app_dir, project: None)
|
||||
|
||||
|
||||
def _write_bundled(bundled, name, manifest=None, env_example=None):
|
||||
app = bundled / name
|
||||
app.mkdir()
|
||||
(app / "manifest.json").write_text(json.dumps(manifest or VALID_MANIFEST))
|
||||
(app / "docker-compose.yaml").write_text("services: {}\n")
|
||||
if env_example is not None:
|
||||
(app / ".env.example").write_text(env_example)
|
||||
return app
|
||||
|
||||
|
||||
def test_list_installed_empty(fake_dirs):
|
||||
assert api._list_installed() == []
|
||||
|
||||
|
||||
def test_list_bundled_empty(fake_dirs):
|
||||
assert api._list_bundled() == []
|
||||
|
||||
|
||||
def test_list_bundled_shows_uninstalled(fake_dirs):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare")
|
||||
out = api._list_bundled()
|
||||
assert len(out) == 1
|
||||
assert out[0]["name"] == "fileshare"
|
||||
assert "display_name" in out[0]
|
||||
|
||||
|
||||
def test_list_bundled_hides_already_installed(fake_dirs, no_docker):
|
||||
apps, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", env_example="A=real")
|
||||
status, _ = api._do_install("fileshare")
|
||||
assert status == 200
|
||||
# Now bundled should NOT include fileshare anymore.
|
||||
assert api._list_bundled() == []
|
||||
# But installed list should.
|
||||
installed = api._list_installed()
|
||||
assert len(installed) == 1 and installed[0]["name"] == "fileshare"
|
||||
|
||||
|
||||
def test_install_endpoint_rejects_placeholder(fake_dirs):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", env_example="SMB_PASSWORD=changeme")
|
||||
status, body = api._do_install("fileshare")
|
||||
assert status == 400
|
||||
assert "placeholder" in body["error"]
|
||||
|
||||
|
||||
def test_install_endpoint_rejects_unknown_app(fake_dirs):
|
||||
status, body = api._do_install("does-not-exist")
|
||||
assert status == 400
|
||||
assert "not found" in body["error"]
|
||||
|
||||
|
||||
def test_remove_endpoint_unknown(fake_dirs, no_docker):
|
||||
status, body = api._do_remove("ghost")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_remove_endpoint_happy_path(fake_dirs, no_docker):
|
||||
apps, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", env_example="A=real")
|
||||
api._do_install("fileshare")
|
||||
assert (apps / "fileshare").exists()
|
||||
status, body = api._do_remove("fileshare")
|
||||
assert status == 200
|
||||
assert body["removed"] == "fileshare"
|
||||
assert not (apps / "fileshare").exists()
|
||||
|
||||
|
||||
def test_http_get_apps_route(fake_dirs, no_docker):
|
||||
"""Smoke test the actual HTTP server with a real socket, urllib client."""
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps") as r:
|
||||
assert r.status == 200
|
||||
data = json.loads(r.read())
|
||||
assert data == []
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as r:
|
||||
assert r.status == 200
|
||||
assert b"Furtka Apps" in r.read()
|
||||
# Unknown route → 404 JSON.
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nope")
|
||||
raise AssertionError("expected 404")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 404
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_http_post_install_unknown_app(fake_dirs):
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/api/apps/install",
|
||||
data=json.dumps({"name": "ghost"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
raise AssertionError("expected 400")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 400
|
||||
body = json.loads(e.read())
|
||||
assert "not found" in body["error"]
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
|
@ -141,9 +141,11 @@ def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path):
|
|||
assert "tar -xzf - -C /opt/furtka" in joined
|
||||
# `furtka` CLI wrapper lands on the target.
|
||||
assert "/usr/local/bin/furtka" in joined
|
||||
# systemd unit is written and conditionally enabled.
|
||||
# systemd units are written and conditionally enabled.
|
||||
assert "/etc/systemd/system/furtka-reconcile.service" in joined
|
||||
assert "/etc/systemd/system/furtka-api.service" in joined
|
||||
assert "furtka-reconcile.service" in joined
|
||||
assert "furtka-api.service" in joined
|
||||
# python is pacstrapped so the wrapper has an interpreter.
|
||||
assert "python" in cfg["packages"]
|
||||
|
||||
|
|
@ -169,7 +171,9 @@ def test_resource_manager_absent_without_payload(monkeypatch, tmp_path):
|
|||
joined = "\n".join(cfg["custom_commands"])
|
||||
|
||||
assert "tar -xzf - -C /opt/furtka" not in joined
|
||||
assert "furtka-reconcile.service" in joined # still in the conditional enable line
|
||||
# The conditional enable line still mentions the units (gated by [ -e ]).
|
||||
assert "furtka-reconcile.service" in joined
|
||||
assert "furtka-api.service" in joined
|
||||
# The base system bootstrap (caddy etc) is unaffected.
|
||||
assert "/etc/caddy/Caddyfile" in joined
|
||||
|
||||
|
|
|
|||
|
|
@ -146,12 +146,22 @@ def build_disk_config(boot_drive):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CADDYFILE = """\
|
||||
# Serves the Furtka landing page + status.json on :80. Static only for now;
|
||||
# reverse_proxy / TLS / auth come later when Authentik is wired in.
|
||||
# Serves the Furtka landing page + status.json on :80. Static for the
|
||||
# landing page; /apps and /api are reverse-proxied to the local resource-
|
||||
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
|
||||
# later when Authentik is wired in.
|
||||
:80 {
|
||||
\troot * /srv/furtka/www
|
||||
\tfile_server
|
||||
\tencode gzip
|
||||
\thandle /api/* {
|
||||
\t\treverse_proxy localhost:7000
|
||||
\t}
|
||||
\thandle /apps* {
|
||||
\t\treverse_proxy localhost:7000
|
||||
\t}
|
||||
\thandle {
|
||||
\t\troot * /srv/furtka/www
|
||||
\t\tfile_server
|
||||
\t\tencode gzip
|
||||
\t}
|
||||
\tlog {
|
||||
\t\toutput stdout
|
||||
\t}
|
||||
|
|
@ -195,8 +205,8 @@ _INDEX_HTML = """\
|
|||
</section>
|
||||
|
||||
<section class="soon">
|
||||
<h2>App store</h2>
|
||||
<p>Coming soon — one-click installs for Nextcloud, Jellyfin, and friends.</p>
|
||||
<h2>Apps</h2>
|
||||
<p><a href="/apps">Manage installed apps →</a></p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
|
|
@ -430,6 +440,23 @@ RemainAfterExit=no
|
|||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
_FURTKA_API_SERVICE = """\
|
||||
[Unit]
|
||||
Description=Furtka resource-manager HTTP API + UI
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target furtka-reconcile.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
def _write_file_cmd(path, content, mode=None):
|
||||
"""Shell command that recreates `path` with `content` inside the chroot.
|
||||
|
|
@ -468,6 +495,7 @@ def _resource_manager_commands():
|
|||
untar_cmd,
|
||||
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
|
||||
_write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE),
|
||||
_write_file_cmd("/etc/systemd/system/furtka-api.service", _FURTKA_API_SERVICE),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -501,11 +529,13 @@ def _post_install_commands(hostname):
|
|||
# custom_commands runs, so our own unit files aren't on disk yet at
|
||||
# that point. Enable them here, after they exist. caddy /
|
||||
# avahi-daemon stay in the `services` list — those are packaged
|
||||
# units, present right after pacstrap. furtka-reconcile is enabled
|
||||
# only if the resource manager payload was actually installed above.
|
||||
# units, present right after pacstrap. furtka-reconcile +
|
||||
# furtka-api are enabled only if the resource manager payload was
|
||||
# actually installed above; the conditional keeps systemctl green
|
||||
# on dev / payload-less builds.
|
||||
"systemctl enable furtka-welcome.service furtka-status.timer "
|
||||
"$([ -e /etc/systemd/system/furtka-reconcile.service ] "
|
||||
"&& echo furtka-reconcile.service)",
|
||||
"&& echo furtka-reconcile.service furtka-api.service)",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue