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>
This commit is contained in:
parent
28e82bfccb
commit
cfc4c0b9c1
10 changed files with 545 additions and 2 deletions
126
docs/resource-manager.md
Normal file
126
docs/resource-manager.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Resource Manager — Design Notes
|
||||
|
||||
Scaffold for the conversation between Daniel and Robert. Captures what Robert already sketched in chat on 2026-04-15 and lists the open questions that need an answer before we write code.
|
||||
|
||||
**Status:** Draft 2026-04-15. Nothing implemented. First app to consume this will be a LAN fileshare (SMB/NFS) — that's the forcing function.
|
||||
|
||||
---
|
||||
|
||||
## What's the Resource Manager?
|
||||
|
||||
The layer between Furtka apps and the underlying system (disk, Docker, network, users). Apps don't touch Docker or the filesystem directly — they declare what they need, the Resource Manager provisions and tracks it.
|
||||
|
||||
Robert's framing (2026-04-15): *"żeby później można było manipulować wszystkimi peryferiami"* — so we can manipulate all peripherals from one place later.
|
||||
|
||||
---
|
||||
|
||||
## Decided (Robert, 2026-04-15)
|
||||
|
||||
1. **An app is a folder.** A Furtka app is defined as a directory containing:
|
||||
- a manifest
|
||||
- a `docker-compose.yaml`
|
||||
- a `.env` file
|
||||
2. **A registration script** reads that folder and wires the app into the backend.
|
||||
3. **Each app gets its own named Docker volume.** Sharing between apps is possible but opt-in, not default.
|
||||
4. **Filesystem is the source of truth (for now).** No SQL database yet — the Resource Manager reads current state from Docker and the OS ad-hoc. DB gets added when we actually need to store intent the OS doesn't know (e.g. "this share belongs to App X, readable by User Y").
|
||||
|
||||
These are locked; don't re-litigate without Robert.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
These need an answer before the first line of code.
|
||||
|
||||
### Q1 — What's in the manifest?
|
||||
|
||||
This is the contract between app authors and Furtka. Changing it later is expensive. Known candidates:
|
||||
|
||||
- `name` (machine id, must match folder name?)
|
||||
- `display_name` (shown in UI)
|
||||
- `version` (semver? calver?)
|
||||
- `description`
|
||||
- `volumes` (list of named volumes the app needs)
|
||||
- `ports` (which ports the app wants exposed; needed for reverse proxy later)
|
||||
- `icon` (path or URL)
|
||||
- ... anything else?
|
||||
|
||||
**Format:** YAML / TOML / JSON — not decided.
|
||||
|
||||
### Q2 — Where do apps live on disk?
|
||||
|
||||
Suggested-but-not-decided: `/var/lib/furtka/apps/<app-name>/`. Robert: confirm path?
|
||||
|
||||
### Q3 — What does the registration script actually do?
|
||||
|
||||
Robert said "Skript do rejestrowania w backendzie". Concretely that means some subset of:
|
||||
|
||||
- Validate manifest (schema check, required fields, volume names unique)
|
||||
- Create any declared named volumes that don't exist yet
|
||||
- Run `docker compose up -d` inside the app folder
|
||||
- Record the app in whatever passes for the backend index (right now: nothing — we'd just re-scan the folder each time)
|
||||
|
||||
**Sub-question:** is registration a one-shot on install, or does something (systemd unit?) re-scan `/var/lib/furtka/apps/` on every boot so the filesystem really is authoritative?
|
||||
|
||||
### Q4 — How does the user actually install an app?
|
||||
|
||||
Out of scope for the first cut, but needed to know the shape of the CLI:
|
||||
|
||||
- Admin UI with a button "Install Fileshare"?
|
||||
- CLI: `furtka app install <tarball>` / `<git repo>` / `<name-from-catalog>`?
|
||||
- Catalog = a separate repo with app folders?
|
||||
|
||||
### Q5 — Upgrades and removal
|
||||
|
||||
- Upgrade strategy: replace folder + re-run compose? Preserve volumes across upgrades (yes obviously, but needs stating)?
|
||||
- Removal: does it delete the volume or keep it? Robert's "apki się dzielą" implies a volume can outlive its original app.
|
||||
|
||||
### Q6 — Volume sharing semantics
|
||||
|
||||
Robert: "jak będzie trzeba to będą mogły się dzielić". Open:
|
||||
- Who requests the share — the sharing app's manifest, or an admin action?
|
||||
- What's the permission model (read-only, read-write, per-user)?
|
||||
- This is probably the *first* piece of "intent not reflected in OS state" — might be what finally motivates the DB.
|
||||
|
||||
### Q7 — Backend API surface
|
||||
|
||||
Once the filesystem model is clear, the Resource Manager's backend still needs *some* Python/whatever surface that the web UI and the register script both call. Rough shape:
|
||||
|
||||
- `list_apps()` — scan `/var/lib/furtka/apps/`, return manifests + running status
|
||||
- `install_app(folder)` — validate + compose up
|
||||
- `remove_app(name)` — compose down, optionally rm volume
|
||||
- `list_volumes()` — from Docker
|
||||
- `list_shares()` — ???
|
||||
|
||||
Not decided whether this lives in the existing `webinstaller` Flask app, a new backend service, or a thin CLI that the Flask app shells out to.
|
||||
|
||||
---
|
||||
|
||||
## First consumer — Fileshare
|
||||
|
||||
The point of doing this now is to unblock the fileshare app (see `../memory/project_first_app_fileshare.md` for the conversation context). Walking it through the model above:
|
||||
|
||||
```
|
||||
/var/lib/furtka/apps/fileshare/
|
||||
manifest.??? # see Q1 — what exactly goes here?
|
||||
docker-compose.yaml
|
||||
.env
|
||||
```
|
||||
|
||||
- App declares a volume (e.g. `files`).
|
||||
- Compose runs a Samba/NFS container mounting that volume.
|
||||
- On Mac/Windows/Android you mount `smb://furtka.local/files`.
|
||||
|
||||
If we can get *this* end-to-end through the Resource Manager, we've validated the whole model with the simplest possible app. Nextcloud, Jellyfin, etc. are the same shape with more knobs.
|
||||
|
||||
---
|
||||
|
||||
## What we do NOT do yet
|
||||
|
||||
- No database. (Decided.)
|
||||
- No user/permissions model beyond what Samba/OS already give us.
|
||||
- No reverse proxy / TLS integration in the manifest. (Tracked separately — see `../memory/project_ssl_local_deferred.md`.)
|
||||
- No app catalog / store UI. Manual install first.
|
||||
- No auto-updates.
|
||||
|
||||
These all become easier once Q1–Q7 are answered — adding them to an existing model is straightforward; guessing them now is expensive.
|
||||
0
furtka/__init__.py
Normal file
0
furtka/__init__.py
Normal file
90
furtka/cli.py
Normal file
90
furtka/cli.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from furtka.paths import apps_dir
|
||||
from furtka.scanner import scan
|
||||
|
||||
|
||||
def _cmd_app_list(args: argparse.Namespace) -> int:
|
||||
results = scan(apps_dir())
|
||||
if args.json:
|
||||
out = [
|
||||
{
|
||||
"path": str(r.path),
|
||||
"name": r.manifest.name if r.manifest else None,
|
||||
"ok": r.ok,
|
||||
"error": r.error,
|
||||
"manifest": {
|
||||
"name": r.manifest.name,
|
||||
"display_name": r.manifest.display_name,
|
||||
"version": r.manifest.version,
|
||||
"description": r.manifest.description,
|
||||
"volumes": list(r.manifest.volumes),
|
||||
"ports": list(r.manifest.ports),
|
||||
"icon": r.manifest.icon,
|
||||
}
|
||||
if r.manifest
|
||||
else None,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
print(json.dumps(out, indent=2))
|
||||
return 0
|
||||
if not results:
|
||||
print("(no apps installed)")
|
||||
return 0
|
||||
for r in results:
|
||||
if r.ok:
|
||||
m = r.manifest
|
||||
print(f"{m.name:20s} {m.version:10s} {m.display_name}")
|
||||
else:
|
||||
print(f"{r.path.name:20s} ERROR {r.error}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
||||
# Slice 1 stub: report what we see, don't act yet. Reconciler with real
|
||||
# docker compose calls lands in the next slice.
|
||||
results = scan(apps_dir())
|
||||
print(f"Scanned {apps_dir()}: {len(results)} entries")
|
||||
for r in results:
|
||||
status = "ok" if r.ok else f"error: {r.error}"
|
||||
print(f" - {r.path.name}: {status}")
|
||||
if not args.dry_run:
|
||||
print("(reconciler not implemented yet — pass --dry-run to silence this notice)")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
app = sub.add_parser("app", help="Manage installed apps")
|
||||
app_sub = app.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
app_list = app_sub.add_parser("list", help="List installed apps")
|
||||
app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table")
|
||||
app_list.set_defaults(func=_cmd_app_list)
|
||||
|
||||
reconcile = sub.add_parser(
|
||||
"reconcile",
|
||||
help="Bring docker state in line with /var/lib/furtka/apps",
|
||||
)
|
||||
reconcile.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without changing anything",
|
||||
)
|
||||
reconcile.set_defaults(func=_cmd_reconcile)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
73
furtka/manifest.py
Normal file
73
furtka/manifest.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"name",
|
||||
"display_name",
|
||||
"version",
|
||||
"description",
|
||||
"volumes",
|
||||
"ports",
|
||||
"icon",
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
name: str
|
||||
display_name: str
|
||||
version: str
|
||||
description: str
|
||||
volumes: tuple[str, ...]
|
||||
ports: tuple[int, ...]
|
||||
icon: str
|
||||
|
||||
def volume_name(self, short: str) -> str:
|
||||
# Namespace volume names so two apps can each declare e.g. "data"
|
||||
# without colliding in `docker volume ls`.
|
||||
if short not in self.volumes:
|
||||
raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest")
|
||||
return f"furtka_{self.name}_{short}"
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
raise ManifestError(f"{path}: invalid JSON: {e}") from e
|
||||
if not isinstance(raw, dict):
|
||||
raise ManifestError(f"{path}: top-level must be an object")
|
||||
|
||||
missing = [f for f in REQUIRED_FIELDS if f not in raw]
|
||||
if missing:
|
||||
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
name = raw["name"]
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ManifestError(f"{path}: name must be a non-empty string")
|
||||
expected = path.parent.name
|
||||
if name != expected:
|
||||
raise ManifestError(f"{path}: name {name!r} must equal containing folder {expected!r}")
|
||||
|
||||
volumes = raw["volumes"]
|
||||
if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes):
|
||||
raise ManifestError(f"{path}: volumes must be a list of non-empty strings")
|
||||
|
||||
ports = raw["ports"]
|
||||
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
|
||||
raise ManifestError(f"{path}: ports must be a list of integers")
|
||||
|
||||
return Manifest(
|
||||
name=name,
|
||||
display_name=str(raw["display_name"]),
|
||||
version=str(raw["version"]),
|
||||
description=str(raw["description"]),
|
||||
volumes=tuple(volumes),
|
||||
ports=tuple(ports),
|
||||
icon=str(raw["icon"]),
|
||||
)
|
||||
13
furtka/paths.py
Normal file
13
furtka/paths.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_APPS_DIR = Path("/var/lib/furtka/apps")
|
||||
DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/apps")
|
||||
|
||||
|
||||
def apps_dir() -> Path:
|
||||
return Path(os.environ.get("FURTKA_APPS_DIR", DEFAULT_APPS_DIR))
|
||||
|
||||
|
||||
def bundled_apps_dir() -> Path:
|
||||
return Path(os.environ.get("FURTKA_BUNDLED_APPS_DIR", DEFAULT_BUNDLED_APPS_DIR))
|
||||
35
furtka/scanner.py
Normal file
35
furtka/scanner.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from furtka.manifest import Manifest, ManifestError, load_manifest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
path: Path
|
||||
manifest: Manifest | None
|
||||
error: str | None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.manifest is not None
|
||||
|
||||
|
||||
def scan(apps_root: Path) -> list[ScanResult]:
|
||||
if not apps_root.exists():
|
||||
return []
|
||||
out: list[ScanResult] = []
|
||||
for entry in sorted(apps_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
manifest_path = entry / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
out.append(ScanResult(path=entry, manifest=None, error="manifest.json missing"))
|
||||
continue
|
||||
try:
|
||||
m = load_manifest(manifest_path)
|
||||
except ManifestError as e:
|
||||
out.append(ScanResult(path=entry, manifest=None, error=str(e)))
|
||||
continue
|
||||
out.append(ScanResult(path=entry, manifest=m, error=None))
|
||||
return out
|
||||
|
|
@ -37,7 +37,10 @@ select = [
|
|||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["webinstaller"]
|
||||
pythonpath = ["webinstaller", "."]
|
||||
|
||||
[project.scripts]
|
||||
furtka = "furtka.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = []
|
||||
packages = ["furtka"]
|
||||
|
|
|
|||
54
tests/test_cli.py
Normal file
54
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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",
|
||||
"volumes": ["files"],
|
||||
"ports": [445],
|
||||
"icon": "icon.svg",
|
||||
}
|
||||
)
|
||||
)
|
||||
rc = main(["app", "list", "--json"])
|
||||
assert rc == 0
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert len(data) == 1
|
||||
assert data[0]["ok"] is True
|
||||
assert data[0]["manifest"]["name"] == "fileshare"
|
||||
|
||||
|
||||
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 entries" in out
|
||||
80
tests/test_manifest.py
Normal file
80
tests/test_manifest.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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)
|
||||
|
||||
|
||||
def test_name_must_match_folder(tmp_path):
|
||||
path = _write_app(tmp_path, "wrong-folder", VALID_MANIFEST)
|
||||
with pytest.raises(ManifestError, match="must equal containing folder"):
|
||||
load_manifest(path)
|
||||
|
||||
|
||||
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)
|
||||
69
tests/test_scanner.py
Normal file
69
tests/test_scanner.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import json
|
||||
|
||||
from furtka.scanner import scan
|
||||
|
||||
VALID_MANIFEST = {
|
||||
"name": "fileshare",
|
||||
"display_name": "Network Files",
|
||||
"version": "0.1.0",
|
||||
"description": "SMB share",
|
||||
"volumes": ["files"],
|
||||
"ports": [445],
|
||||
"icon": "icon.svg",
|
||||
}
|
||||
|
||||
|
||||
def _make_app(root, name, manifest=None):
|
||||
app = root / name
|
||||
app.mkdir(parents=True)
|
||||
if manifest is not None:
|
||||
(app / "manifest.json").write_text(json.dumps(manifest))
|
||||
return app
|
||||
|
||||
|
||||
def test_scan_missing_root(tmp_path):
|
||||
assert scan(tmp_path / "does-not-exist") == []
|
||||
|
||||
|
||||
def test_scan_empty_root(tmp_path):
|
||||
assert scan(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_valid_app(tmp_path):
|
||||
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||
results = scan(tmp_path)
|
||||
assert len(results) == 1
|
||||
assert results[0].ok
|
||||
assert results[0].manifest.name == "fileshare"
|
||||
|
||||
|
||||
def test_scan_skips_files(tmp_path):
|
||||
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||
(tmp_path / "stray.txt").write_text("ignore me")
|
||||
results = scan(tmp_path)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_scan_missing_manifest(tmp_path):
|
||||
_make_app(tmp_path, "broken")
|
||||
results = scan(tmp_path)
|
||||
assert len(results) == 1
|
||||
assert not results[0].ok
|
||||
assert "manifest.json missing" in results[0].error
|
||||
|
||||
|
||||
def test_scan_invalid_manifest(tmp_path):
|
||||
bad = dict(VALID_MANIFEST)
|
||||
del bad["volumes"]
|
||||
_make_app(tmp_path, "fileshare", bad)
|
||||
results = scan(tmp_path)
|
||||
assert len(results) == 1
|
||||
assert not results[0].ok
|
||||
assert "volumes" in results[0].error
|
||||
|
||||
|
||||
def test_scan_sorted_by_name(tmp_path):
|
||||
_make_app(tmp_path, "z-app", dict(VALID_MANIFEST, name="z-app"))
|
||||
_make_app(tmp_path, "a-app", dict(VALID_MANIFEST, name="a-app"))
|
||||
results = scan(tmp_path)
|
||||
assert [r.path.name for r in results] == ["a-app", "z-app"]
|
||||
Loading…
Add table
Reference in a new issue