furtka/tests/test_installer.py

358 lines
13 KiB
Python
Raw Normal View History

feat(furtka): reconciler + install/remove — slice 2 Fills in the act-on-it half of the resource manager. Reconciler walks the scanner output and brings docker into the desired state: ensures each manifest-declared volume exists (idempotent), then runs docker compose up -d for the project. install/remove on the CLI work end-to-end against a real /var/lib/furtka/apps/ tree. - furtka.dockerops: thin subprocess wrapper. Volume + compose primitives that other modules call. `_run` raises DockerError with the actual stderr so failures are diagnosable. - furtka.reconciler: builds an ordered Action list (volumes then compose_up per app), executes unless dry-run. Broken manifests produce a "skip" action, the rest of the apps still get reconciled. - furtka.installer: copy-from-source with two non-obvious rules — user .env is preserved across upgrade installs, and a missing .env is bootstrapped from .env.example so compose has values to substitute on first install. Bundled-app lookup falls back to /opt/furtka/apps/<name>/ when the source arg isn't a path. - furtka.cli: app install/remove wired up. remove() ignores compose down failures so a botched compose doesn't trap users with an un-removable folder. - 15 new tests using monkeypatch'd dockerops so the suite still runs without docker installed. Covers reconcile dry-run, multi-volume apps, broken-manifest skip behavior, .env preservation, bundled-name resolution, and remove edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:02:00 +02:00
import json
import pytest
from furtka import installer
from furtka.paths import apps_dir, bundled_apps_dir
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
def _write_app_source(root, name, manifest, env_example=None, env=None):
app = root / name
app.mkdir()
(app / "manifest.json").write_text(json.dumps(manifest))
(app / "docker-compose.yaml").write_text("services: {}\n")
if env_example is not None:
(app / ".env.example").write_text(env_example)
if env is not None:
(app / ".env").write_text(env)
return app
def test_resolve_source_explicit_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST)
resolved = installer.resolve_source(str(src))
assert resolved == src
def test_resolve_source_bundled_name(fake_dirs):
_, bundled = fake_dirs
src = _write_app_source(bundled, "fileshare", VALID_MANIFEST)
resolved = installer.resolve_source("fileshare")
assert resolved == src
def test_resolve_source_unknown_name(fake_dirs):
with pytest.raises(installer.InstallError, match="not found"):
installer.resolve_source("nope")
def test_resolve_source_path_with_slash_must_exist(fake_dirs):
with pytest.raises(installer.InstallError, match="not a directory"):
installer.resolve_source("./does-not-exist")
def test_install_from_copies_files(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=1")
target = installer.install_from(src)
assert target == apps_dir() / "fileshare"
assert (target / "manifest.json").exists()
assert (target / "docker-compose.yaml").exists()
assert (target / ".env.example").exists()
# .env bootstrapped from .env.example since none was shipped
assert (target / ".env").read_text() == "A=1"
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
target = apps_dir() / "fileshare"
target.mkdir()
(target / ".env").write_text("A=user-edited")
installer.install_from(src)
# User .env not clobbered.
assert (target / ".env").read_text() == "A=user-edited"
# But .env.example was updated.
assert (target / ".env.example").read_text() == "A=new"
def test_install_from_rejects_missing_manifest(tmp_path, fake_dirs):
src = tmp_path / "broken"
src.mkdir()
with pytest.raises(installer.InstallError, match="manifest.json"):
installer.install_from(src)
2026-04-15 10:17:00 +02:00
def test_install_from_arbitrary_source_folder_name(tmp_path, fake_dirs):
# Source folder named "downloaded-fileshare-fork-v2" but manifest says
# "fileshare" — install lands at /var/lib/furtka/apps/fileshare/ regardless.
src = _write_app_source(
tmp_path,
"downloaded-fileshare-fork-v2",
VALID_MANIFEST,
env_example="A=real-value",
)
target = installer.install_from(src)
assert target.name == "fileshare"
assert (target / "manifest.json").exists()
feat(furtka): reconciler + install/remove — slice 2 Fills in the act-on-it half of the resource manager. Reconciler walks the scanner output and brings docker into the desired state: ensures each manifest-declared volume exists (idempotent), then runs docker compose up -d for the project. install/remove on the CLI work end-to-end against a real /var/lib/furtka/apps/ tree. - furtka.dockerops: thin subprocess wrapper. Volume + compose primitives that other modules call. `_run` raises DockerError with the actual stderr so failures are diagnosable. - furtka.reconciler: builds an ordered Action list (volumes then compose_up per app), executes unless dry-run. Broken manifests produce a "skip" action, the rest of the apps still get reconciled. - furtka.installer: copy-from-source with two non-obvious rules — user .env is preserved across upgrade installs, and a missing .env is bootstrapped from .env.example so compose has values to substitute on first install. Bundled-app lookup falls back to /opt/furtka/apps/<name>/ when the source arg isn't a path. - furtka.cli: app install/remove wired up. remove() ignores compose down failures so a botched compose doesn't trap users with an un-removable folder. - 15 new tests using monkeypatch'd dockerops so the suite still runs without docker installed. Covers reconcile dry-run, multi-volume apps, broken-manifest skip behavior, .env preservation, bundled-name resolution, and remove edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:02:00 +02:00
def test_install_from_rejects_invalid_manifest(tmp_path, fake_dirs):
bad = dict(VALID_MANIFEST)
del bad["volumes"]
src = _write_app_source(tmp_path, "fileshare", bad)
with pytest.raises(installer.InstallError, match="volumes"):
installer.install_from(src)
def test_remove_deletes_folder(fake_dirs):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
(apps / "fileshare" / "manifest.json").write_text("{}")
installer.remove("fileshare")
assert not (apps / "fileshare").exists()
def test_remove_unknown_raises(fake_dirs):
with pytest.raises(installer.InstallError, match="not installed"):
installer.remove("ghost")
def test_bundled_apps_dir_uses_env_override(fake_dirs):
_, bundled = fake_dirs
assert bundled_apps_dir() == bundled
2026-04-15 10:17:00 +02:00
def test_install_refuses_placeholder_password(tmp_path, fake_dirs):
src = _write_app_source(
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=changeme"
)
with pytest.raises(installer.InstallError, match="placeholder values for SMB_PASSWORD"):
installer.install_from(src)
# Files should still have landed so the user can vim the .env in place.
target = apps_dir() / "fileshare"
assert (target / ".env").exists()
assert (target / "manifest.json").exists()
def test_install_succeeds_after_user_edits_env(tmp_path, fake_dirs):
# First run: refuses placeholder.
src = _write_app_source(
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=changeme"
)
with pytest.raises(installer.InstallError):
installer.install_from(src)
# User edits the live .env to a real secret.
target = apps_dir() / "fileshare"
(target / ".env").write_text("SMB_PASSWORD=hunter2\n")
# Re-run: now succeeds, user .env preserved.
installer.install_from(src)
assert (target / ".env").read_text().strip() == "SMB_PASSWORD=hunter2"
def test_install_locks_env_permissions(tmp_path, fake_dirs):
src = _write_app_source(
tmp_path, "fileshare", VALID_MANIFEST, env_example="SMB_PASSWORD=hunter2"
)
installer.install_from(src)
target = apps_dir() / "fileshare"
mode = (target / ".env").stat().st_mode & 0o777
assert mode == 0o600, f"expected 0o600 on .env, got {oct(mode)}"
def test_placeholder_check_ignores_comments_and_blanks(tmp_path, fake_dirs):
src = _write_app_source(
tmp_path,
"fileshare",
VALID_MANIFEST,
env_example="# default values\n\nSMB_PASSWORD=real-secret\n",
)
# Should NOT raise — only commented "changeme" mentions, no actual placeholder.
installer.install_from(src)
def test_placeholder_check_handles_quoted_values(tmp_path, fake_dirs):
src = _write_app_source(
tmp_path,
"fileshare",
VALID_MANIFEST,
env_example='SMB_PASSWORD="changeme"\n',
)
with pytest.raises(installer.InstallError, match="placeholder"):
installer.install_from(src)
feat(furtka): in-browser app settings + ISO recovery-path fixes End-to-end VM test today (2026-04-15) validated the resource manager golden path but exposed four things blocking "dein-Vater-tauglich": no way to configure an app without SSH+editor, no openssh, no nano, keyboard stuck on US, and a samba healthcheck that cried wolf. Resource-manager side: - Manifest schema gains optional `settings` list (name/label/ description/type/required/default) and `description_long`. - Bundled-app install opens a form rendered from the manifest; submit carries values to `POST /api/apps/install` which writes them into the new app's `.env` before the placeholder check runs. - Installed apps grow an "Einstellungen" button that merges a partial settings dict into the existing `.env` (unsubmitted password fields = keep current), then reconciles to restart. - New endpoints: `GET/POST /api/apps/<name>/settings`. Passwords are never returned to the client. - Fileshare manifest declares its SMB_USER/SMB_PASSWORD settings in German with help text. ISO side (so the next build is actually usable on the TTY): - Add `openssh` to the package list + `sshd` to enabled services. `archinstall: true` in 4.x did not install openssh-server. - Add `nano` — `vim` was the only editor pitched at users, which is brutal for first-timers (and was missing anyway). - Keyboard layout follows the installer language (`de→de`, `pl→pl`, `en→us`) instead of hardcoded `us`. A German user couldn't type `/` or `-` at the console, making even `sudo nano` painful. - Disable the dperson/samba healthcheck in the compose override — it timed out on every probe while the share itself worked fine. 19 new tests (manifest parsing + settings-merge + two new API endpoints over live HTTP); 94 total, format + lint clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:00:02 +02:00
# --- Settings-driven install -------------------------------------------------
SETTINGS_MANIFEST = dict(
VALID_MANIFEST,
settings=[
{
"name": "SMB_USER",
"label": "User",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
],
)
def test_install_with_settings_writes_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_rejects_empty_required(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# SMB_PASSWORD has no default and is required — submitting empty is rejected.
with pytest.raises(installer.InstallError, match="'SMB_PASSWORD' is required"):
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": ""})
def test_install_with_settings_rejects_unknown_key(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
with pytest.raises(installer.InstallError, match="unknown setting 'FOO'"):
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b", "FOO": "x"})
def test_install_settings_merge_preserves_unchanged(tmp_path, fake_dirs):
# First install with full settings.
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
# Second call with only password — user should keep existing user name.
installer.install_from(src, settings={"SMB_PASSWORD": "newpass"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=newpass" in env
def test_install_settings_applies_defaults_on_first_install(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# Only password submitted; SMB_USER falls through to its manifest default
# ("furtka") and the required check passes because the merged view has it.
installer.install_from(src, settings={"SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=furtka" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_writes_0600(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b"})
mode = (apps_dir() / "fileshare" / ".env").stat().st_mode & 0o777
assert mode == 0o600
def test_read_env_values_roundtrip(tmp_path, fake_dirs):
from furtka.installer import read_env_values, write_env
p = tmp_path / ".env"
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
values = read_env_values(p)
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
# --- path-type settings ------------------------------------------------------
PATH_MANIFEST = dict(
VALID_MANIFEST,
name="jellyfin",
settings=[
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"type": "path",
"required": True,
}
],
)
OPTIONAL_PATH_MANIFEST = dict(
VALID_MANIFEST,
name="jellyfin",
settings=[{"name": "OPTIONAL_PATH", "label": "Optional", "type": "path", "required": False}],
)
def test_install_with_valid_path_succeeds(tmp_path, fake_dirs):
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
target = apps_dir() / "jellyfin"
assert f"MEDIA_PATH={media}" in (target / ".env").read_text()
def test_install_rejects_nonexistent_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="does not exist"):
installer.install_from(src, settings={"MEDIA_PATH": str(tmp_path / "ghost")})
def test_install_rejects_path_that_is_a_file(tmp_path, fake_dirs):
f = tmp_path / "not-a-dir"
f.write_text("hi")
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="is not a directory"):
installer.install_from(src, settings={"MEDIA_PATH": str(f)})
def test_install_rejects_relative_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="absolute path"):
installer.install_from(src, settings={"MEDIA_PATH": "media"})
def test_install_rejects_system_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/etc"})
def test_install_rejects_root_filesystem(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/"})
def test_install_rejects_deny_list_via_traversal(tmp_path, fake_dirs):
# /mnt/../etc resolves to /etc — must be caught after Path.resolve().
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
with pytest.raises(installer.InstallError, match="system path"):
installer.install_from(src, settings={"MEDIA_PATH": "/mnt/../etc"})
def test_install_accepts_empty_optional_path(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "jellyfin", OPTIONAL_PATH_MANIFEST)
installer.install_from(src, settings={"OPTIONAL_PATH": ""})
target = apps_dir() / "jellyfin"
assert (target / ".env").exists()
def test_update_env_rejects_invalid_path(tmp_path, fake_dirs):
# First install with a valid path.
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
# Then try to update to a bad path.
with pytest.raises(installer.InstallError, match="does not exist"):
installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")})