feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from furtka.manifest import Manifest, ManifestError, load_manifest
|
|
|
|
|
|
|
|
|
|
VALID_MANIFEST = {
|
|
|
|
|
"name": "fileshare",
|
|
|
|
|
"display_name": "Network Files",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"description": "SMB share",
|
|
|
|
|
"volumes": ["files"],
|
|
|
|
|
"ports": [445],
|
|
|
|
|
"icon": "icon.svg",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_app(tmp_path, name, payload):
|
|
|
|
|
app_dir = tmp_path / name
|
|
|
|
|
app_dir.mkdir()
|
|
|
|
|
(app_dir / "manifest.json").write_text(json.dumps(payload))
|
|
|
|
|
return app_dir / "manifest.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_valid_manifest(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert isinstance(m, Manifest)
|
|
|
|
|
assert m.name == "fileshare"
|
|
|
|
|
assert m.volumes == ("files",)
|
|
|
|
|
assert m.ports == (445,)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_volume_namespacing(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.volume_name("files") == "furtka_fileshare_files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_volume_raises(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
with pytest.raises(ManifestError):
|
|
|
|
|
m.volume_name("does-not-exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_required_field(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST)
|
|
|
|
|
del bad["display_name"]
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="display_name"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
def test_name_must_match_when_expected_name_given(tmp_path):
|
|
|
|
|
# Scanner passes expected_name=<folder name> so /var/lib/furtka/apps/X/
|
|
|
|
|
# can't lie about its own identity.
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
path = _write_app(tmp_path, "wrong-folder", VALID_MANIFEST)
|
2026-04-15 10:17:00 +02:00
|
|
|
with pytest.raises(ManifestError, match="must equal 'wrong-folder'"):
|
|
|
|
|
load_manifest(path, expected_name="wrong-folder")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_name_check_skipped_without_expected_name(tmp_path):
|
|
|
|
|
# Installer loads from arbitrary source paths (e.g. /tmp/my-tweaked-app/)
|
|
|
|
|
# — the source folder name shouldn't matter, only the manifest's own name.
|
|
|
|
|
path = _write_app(tmp_path, "any-folder-name", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.name == "fileshare"
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_json(tmp_path):
|
|
|
|
|
app = tmp_path / "fileshare"
|
|
|
|
|
app.mkdir()
|
|
|
|
|
(app / "manifest.json").write_text("{not json")
|
|
|
|
|
with pytest.raises(ManifestError, match="invalid JSON"):
|
|
|
|
|
load_manifest(app / "manifest.json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_volumes_wrong_type(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, volumes="files")
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="volumes"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ports_wrong_type(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, ports=["445"])
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="ports"):
|
|
|
|
|
load_manifest(path)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_optional_default_empty(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.settings == ()
|
|
|
|
|
assert m.description_long == ""
|
2026-04-20 15:44:01 +02:00
|
|
|
assert m.open_url == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_url_stored_when_present(tmp_path):
|
|
|
|
|
payload = dict(VALID_MANIFEST, open_url="smb://{host}/files")
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.open_url == "smb://{host}/files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_url_non_string_rejected(tmp_path):
|
|
|
|
|
payload = dict(VALID_MANIFEST, open_url=42)
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
|
|
|
with pytest.raises(ManifestError, match="open_url"):
|
|
|
|
|
load_manifest(path)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_parsed(tmp_path):
|
|
|
|
|
payload = dict(
|
|
|
|
|
VALID_MANIFEST,
|
|
|
|
|
description_long="Long description with details.",
|
|
|
|
|
settings=[
|
|
|
|
|
{
|
|
|
|
|
"name": "SMB_USER",
|
|
|
|
|
"label": "Benutzername",
|
|
|
|
|
"description": "Anmeldename",
|
|
|
|
|
"type": "text",
|
|
|
|
|
"default": "furtka",
|
|
|
|
|
"required": True,
|
|
|
|
|
},
|
|
|
|
|
{"name": "SMB_PASSWORD", "label": "Passwort", "type": "password", "required": True},
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", payload)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.description_long == "Long description with details."
|
|
|
|
|
assert len(m.settings) == 2
|
|
|
|
|
assert m.settings[0].name == "SMB_USER"
|
|
|
|
|
assert m.settings[0].label == "Benutzername"
|
|
|
|
|
assert m.settings[0].default == "furtka"
|
|
|
|
|
assert m.settings[0].type == "text"
|
|
|
|
|
assert m.settings[0].required is True
|
|
|
|
|
assert m.settings[1].type == "password"
|
|
|
|
|
assert m.settings[1].default is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_reject_lowercase_name(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, settings=[{"name": "smb_user", "type": "text"}])
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="UPPER_SNAKE_CASE"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_reject_unknown_type(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, settings=[{"name": "FOO", "type": "email"}])
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="type must be one of"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_reject_duplicate_name(tmp_path):
|
|
|
|
|
bad = dict(
|
|
|
|
|
VALID_MANIFEST,
|
|
|
|
|
settings=[{"name": "FOO", "type": "text"}, {"name": "FOO", "type": "password"}],
|
|
|
|
|
)
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="duplicate"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_non_list_rejected(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, settings={"FOO": "bar"})
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="settings must be a list"):
|
|
|
|
|
load_manifest(path)
|