All checks were successful
Build ISO / build-iso (push) Successful in 17m24s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 43s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 16s
Release / release (push) Successful in 11m34s
POST /api/apps/install now returns 202 Accepted after the synchronous
pre-validation (resolve source, copy files, write .env, check for
placeholder secrets, validate path-type settings). The docker-facing
phases (compose pull → ensure volumes → compose up) are dispatched as
a background systemd-run unit (furtka-install-<app>) that writes stage
transitions to /var/lib/furtka/install-state.json. The UI polls
GET /api/apps/install/status every 1.5s and re-labels the modal
submit button — "Image wird heruntergeladen…" →
"Speicherbereiche werden erstellt…" → "Container wird gestartet…" —
instead of sitting dead on "Installing…" for 30+ seconds on large
images like Jellyfin.
Mirrors the exact shape of /api/catalog/sync/apply and
/api/furtka/update/apply: same fcntl lock, same atomic state-file
writes, same terminal-state poll loop ("done" | "error"). New CLI
subcommand `furtka app install-bg <name>` is what systemd-run invokes;
it's hidden from --help because regular CLI users still want the
synchronous `furtka app install <name>`.
Reinstall button on the app list polls too — after dispatch, its text
reflects the background stage until terminal, matching the modal
flow.
Tests:
- tests/test_install_runner.py (new, 9 cases): state roundtrip, lock
contention, happy-path phase ordering, error writes on pull/up
failure, lock release on both terminal outcomes.
- tests/test_api.py: new no_systemd_run fixture stubs subprocess.run;
existing install tests adapted to 202 response; new tests for 409
lock contention and the status endpoint.
- tests/test_cli.py: install-bg dispatches correctly and returns 1
on failure with journald-friendly stderr.
256 tests pass, ruff check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
3.3 KiB
Python
105 lines
3.3 KiB
Python
import json
|
|
|
|
from furtka.cli import main
|
|
|
|
|
|
def _set_env(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(tmp_path))
|
|
|
|
|
|
def test_app_list_empty(tmp_path, monkeypatch, capsys):
|
|
_set_env(monkeypatch, tmp_path)
|
|
rc = main(["app", "list"])
|
|
assert rc == 0
|
|
assert "no apps installed" in capsys.readouterr().out
|
|
|
|
|
|
def test_app_list_json_empty(tmp_path, monkeypatch, capsys):
|
|
_set_env(monkeypatch, tmp_path)
|
|
rc = main(["app", "list", "--json"])
|
|
assert rc == 0
|
|
assert json.loads(capsys.readouterr().out) == []
|
|
|
|
|
|
def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
|
|
_set_env(monkeypatch, tmp_path)
|
|
app = tmp_path / "fileshare"
|
|
app.mkdir()
|
|
(app / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "fileshare",
|
|
"display_name": "Network Files",
|
|
"version": "0.1.0",
|
|
"description": "SMB",
|
|
"description_long": "Long description here.",
|
|
"volumes": ["files"],
|
|
"ports": [445],
|
|
"icon": "icon.svg",
|
|
"open_url": "smb://{host}/files",
|
|
"settings": [
|
|
{
|
|
"name": "SMB_USER",
|
|
"label": "User",
|
|
"description": "SMB user",
|
|
"type": "text",
|
|
"default": "furtka",
|
|
"required": True,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
rc = main(["app", "list", "--json"])
|
|
assert rc == 0
|
|
data = json.loads(capsys.readouterr().out)
|
|
assert len(data) == 1
|
|
assert data[0]["ok"] is True
|
|
m = data[0]["manifest"]
|
|
assert m["name"] == "fileshare"
|
|
assert m["description_long"] == "Long description here."
|
|
assert m["open_url"] == "smb://{host}/files"
|
|
assert len(m["settings"]) == 1
|
|
assert m["settings"][0]["name"] == "SMB_USER"
|
|
assert m["settings"][0]["required"] is True
|
|
assert m["settings"][0]["default"] == "furtka"
|
|
|
|
|
|
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
|
|
_set_env(monkeypatch, tmp_path)
|
|
rc = main(["reconcile", "--dry-run"])
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "0 actions" in out
|
|
|
|
|
|
def test_app_install_bg_dispatches_to_runner(tmp_path, monkeypatch):
|
|
"""CLI `app install-bg <name>` must call install_runner.run_install(name).
|
|
|
|
This is the entry point the HTTP API fires via systemd-run; regression
|
|
here would leave the UI hanging at "pulling_image…" forever because
|
|
the background never transitions state.
|
|
"""
|
|
_set_env(monkeypatch, tmp_path)
|
|
from furtka import install_runner
|
|
|
|
called = []
|
|
monkeypatch.setattr(install_runner, "run_install", lambda name: called.append(name))
|
|
rc = main(["app", "install-bg", "fileshare"])
|
|
assert rc == 0
|
|
assert called == ["fileshare"]
|
|
|
|
|
|
def test_app_install_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
|
|
_set_env(monkeypatch, tmp_path)
|
|
from furtka import install_runner
|
|
|
|
def boom(name):
|
|
raise RuntimeError("compose pull failed")
|
|
|
|
monkeypatch.setattr(install_runner, "run_install", boom)
|
|
rc = main(["app", "install-bg", "fileshare"])
|
|
assert rc == 1
|
|
err = capsys.readouterr().err
|
|
assert "install-bg failed" in err
|
|
assert "compose pull failed" in err
|