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>
177 lines
5.4 KiB
Python
177 lines
5.4 KiB
Python
"""Tests for the background app-install runner.
|
|
|
|
Same shape as test_catalog.py / test_updater.py: fixture reloads the
|
|
module with env-overridden paths, dockerops calls are stubbed so nothing
|
|
touches a real daemon. Asserts that state transitions happen in the
|
|
right order and that exceptions flip the state to "error" with the
|
|
message before re-raising.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def runner(tmp_path, monkeypatch):
|
|
apps = tmp_path / "apps"
|
|
apps.mkdir()
|
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
|
monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json"))
|
|
monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock"))
|
|
|
|
import importlib
|
|
|
|
from furtka import install_runner as r
|
|
from furtka import paths as p
|
|
|
|
importlib.reload(p)
|
|
importlib.reload(r)
|
|
return r
|
|
|
|
|
|
def _write_installed_app(apps_dir: Path, name: str = "fileshare"):
|
|
app = apps_dir / name
|
|
app.mkdir()
|
|
manifest = {
|
|
"name": name,
|
|
"display_name": "Fileshare",
|
|
"version": "0.1.0",
|
|
"description": "Test fixture",
|
|
"volumes": ["files"],
|
|
"ports": [445],
|
|
"icon": "icon.svg",
|
|
}
|
|
(app / "manifest.json").write_text(json.dumps(manifest))
|
|
(app / "docker-compose.yaml").write_text("services: {}\n")
|
|
return app
|
|
|
|
|
|
def test_write_and_read_state_round_trip(runner):
|
|
runner.write_state("pulling_image", app="jellyfin")
|
|
s = runner.read_state()
|
|
assert s["stage"] == "pulling_image"
|
|
assert s["app"] == "jellyfin"
|
|
assert "updated_at" in s
|
|
|
|
|
|
def test_read_state_returns_empty_when_missing(runner):
|
|
assert runner.read_state() == {}
|
|
|
|
|
|
def test_read_state_returns_empty_on_junk(runner):
|
|
runner.state_path().parent.mkdir(parents=True, exist_ok=True)
|
|
runner.state_path().write_text("{not json")
|
|
assert runner.read_state() == {}
|
|
|
|
|
|
def test_acquire_lock_prevents_concurrent_runs(runner):
|
|
held = runner.acquire_lock()
|
|
try:
|
|
with pytest.raises(runner.InstallRunnerError, match="in progress"):
|
|
runner.acquire_lock()
|
|
finally:
|
|
held.close()
|
|
|
|
|
|
def test_run_install_happy_path(runner, monkeypatch):
|
|
import furtka.dockerops as dockerops
|
|
from furtka.paths import apps_dir
|
|
|
|
_write_installed_app(apps_dir(), "fileshare")
|
|
|
|
calls = []
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: calls.append(("pull", a)))
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: calls.append(("vol", name)))
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: calls.append(("up", a)))
|
|
|
|
runner.run_install("fileshare")
|
|
|
|
# Ordering: pull first, then volumes, then up.
|
|
assert [c[0] for c in calls] == ["pull", "vol", "up"]
|
|
# Exactly the namespaced volume name got created.
|
|
assert calls[1] == ("vol", "furtka_fileshare_files")
|
|
# Final state is "done" with the manifest version.
|
|
s = runner.read_state()
|
|
assert s["stage"] == "done"
|
|
assert s["app"] == "fileshare"
|
|
assert s["version"] == "0.1.0"
|
|
|
|
|
|
def test_run_install_writes_error_on_pull_failure(runner, monkeypatch):
|
|
import furtka.dockerops as dockerops
|
|
from furtka.paths import apps_dir
|
|
|
|
_write_installed_app(apps_dir(), "fileshare")
|
|
|
|
def boom(*a, **k):
|
|
raise dockerops.DockerError("pull failed: registry unreachable")
|
|
|
|
monkeypatch.setattr(dockerops, "compose_pull", boom)
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
|
|
|
|
with pytest.raises(dockerops.DockerError):
|
|
runner.run_install("fileshare")
|
|
|
|
s = runner.read_state()
|
|
assert s["stage"] == "error"
|
|
assert s["app"] == "fileshare"
|
|
assert "registry unreachable" in s["error"]
|
|
|
|
|
|
def test_run_install_writes_error_on_up_failure(runner, monkeypatch):
|
|
import furtka.dockerops as dockerops
|
|
from furtka.paths import apps_dir
|
|
|
|
_write_installed_app(apps_dir(), "fileshare")
|
|
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
|
|
|
def boom(*a, **k):
|
|
raise dockerops.DockerError("compose up: container refused to start")
|
|
|
|
monkeypatch.setattr(dockerops, "compose_up", boom)
|
|
|
|
with pytest.raises(dockerops.DockerError):
|
|
runner.run_install("fileshare")
|
|
|
|
s = runner.read_state()
|
|
assert s["stage"] == "error"
|
|
assert "refused to start" in s["error"]
|
|
|
|
|
|
def test_run_install_releases_lock_after_done(runner, monkeypatch):
|
|
import furtka.dockerops as dockerops
|
|
from furtka.paths import apps_dir
|
|
|
|
_write_installed_app(apps_dir(), "fileshare")
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
|
|
|
|
runner.run_install("fileshare")
|
|
|
|
# Lock released — a fresh acquire must succeed.
|
|
fh = runner.acquire_lock()
|
|
fh.close()
|
|
|
|
|
|
def test_run_install_releases_lock_after_error(runner, monkeypatch):
|
|
import furtka.dockerops as dockerops
|
|
from furtka.paths import apps_dir
|
|
|
|
_write_installed_app(apps_dir(), "fileshare")
|
|
monkeypatch.setattr(
|
|
dockerops, "compose_pull", lambda *a, **k: (_ for _ in ()).throw(dockerops.DockerError("x"))
|
|
)
|
|
|
|
with pytest.raises(dockerops.DockerError):
|
|
runner.run_install("fileshare")
|
|
|
|
fh = runner.acquire_lock()
|
|
fh.close()
|