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>
This commit is contained in:
parent
cfc4c0b9c1
commit
7b96a25f5b
7 changed files with 424 additions and 10 deletions
|
|
@ -2,6 +2,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from furtka import dockerops, installer, reconciler
|
||||||
from furtka.paths import apps_dir
|
from furtka.paths import apps_dir
|
||||||
from furtka.scanner import scan
|
from furtka.scanner import scan
|
||||||
|
|
||||||
|
|
@ -43,16 +44,46 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_app_install(args: argparse.Namespace) -> int:
|
||||||
|
try:
|
||||||
|
src = installer.resolve_source(args.source)
|
||||||
|
target = installer.install_from(src)
|
||||||
|
except installer.InstallError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"installed {target.name} to {target}")
|
||||||
|
actions = reconciler.reconcile(apps_dir())
|
||||||
|
for a in actions:
|
||||||
|
print(f" {a.describe()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_app_remove(args: argparse.Namespace) -> int:
|
||||||
|
target = apps_dir() / args.name
|
||||||
|
if not target.exists():
|
||||||
|
print(f"error: {args.name!r} is not installed", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
dockerops.compose_down(target, args.name)
|
||||||
|
except dockerops.DockerError as e:
|
||||||
|
# Container may already be down (or never came up). Don't block removal.
|
||||||
|
print(f"warning: compose down failed, removing folder anyway: {e}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
installer.remove(args.name)
|
||||||
|
except installer.InstallError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"removed {args.name} (volumes preserved)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
||||||
# Slice 1 stub: report what we see, don't act yet. Reconciler with real
|
actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run)
|
||||||
# docker compose calls lands in the next slice.
|
print(f"Scanned {apps_dir()}: {len(actions)} actions")
|
||||||
results = scan(apps_dir())
|
for a in actions:
|
||||||
print(f"Scanned {apps_dir()}: {len(results)} entries")
|
print(f" {a.describe()}")
|
||||||
for r in results:
|
if args.dry_run:
|
||||||
status = "ok" if r.ok else f"error: {r.error}"
|
print("(dry-run — nothing changed)")
|
||||||
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,6 +98,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table")
|
app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table")
|
||||||
app_list.set_defaults(func=_cmd_app_list)
|
app_list.set_defaults(func=_cmd_app_list)
|
||||||
|
|
||||||
|
app_install = app_sub.add_parser(
|
||||||
|
"install",
|
||||||
|
help="Install an app from a local folder or a bundled-app name",
|
||||||
|
)
|
||||||
|
app_install.add_argument(
|
||||||
|
"source",
|
||||||
|
help="Path to an app folder, or the name of a bundled app under /opt/furtka/apps/",
|
||||||
|
)
|
||||||
|
app_install.set_defaults(func=_cmd_app_install)
|
||||||
|
|
||||||
|
app_remove = app_sub.add_parser("remove", help="Stop and uninstall an app (keeps volumes)")
|
||||||
|
app_remove.add_argument("name", help="App name (folder name under /var/lib/furtka/apps/)")
|
||||||
|
app_remove.set_defaults(func=_cmd_app_remove)
|
||||||
|
|
||||||
reconcile = sub.add_parser(
|
reconcile = sub.add_parser(
|
||||||
"reconcile",
|
"reconcile",
|
||||||
help="Bring docker state in line with /var/lib/furtka/apps",
|
help="Bring docker state in line with /var/lib/furtka/apps",
|
||||||
|
|
|
||||||
51
furtka/dockerops.py
Normal file
51
furtka/dockerops.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class DockerError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args: list[str], cwd: Path | None = None, check: bool = True):
|
||||||
|
proc = subprocess.run(
|
||||||
|
args,
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
msg = proc.stderr.strip() or proc.stdout.strip()
|
||||||
|
raise DockerError(f"{' '.join(args)} exited {proc.returncode}: {msg}")
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def volume_exists(name: str) -> bool:
|
||||||
|
return _run(["docker", "volume", "inspect", name], check=False).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_volume(name: str) -> bool:
|
||||||
|
# Returns True if the volume was just created, False if it already existed.
|
||||||
|
if volume_exists(name):
|
||||||
|
return False
|
||||||
|
_run(["docker", "volume", "create", name])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_args(app_dir: Path, project: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"--project-name",
|
||||||
|
project,
|
||||||
|
"--file",
|
||||||
|
str(app_dir / "docker-compose.yaml"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compose_up(app_dir: Path, project: str) -> None:
|
||||||
|
_run([*_compose_args(app_dir, project), "up", "--detach"], cwd=app_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_down(app_dir: Path, project: str) -> None:
|
||||||
|
_run([*_compose_args(app_dir, project), "down"], cwd=app_dir)
|
||||||
74
furtka/installer.py
Normal file
74
furtka/installer.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from furtka.manifest import ManifestError, load_manifest
|
||||||
|
from furtka.paths import apps_dir, bundled_apps_dir
|
||||||
|
|
||||||
|
|
||||||
|
class InstallError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_source(source: str) -> Path:
|
||||||
|
"""Resolve a `furtka app install <source>` arg to a real source folder.
|
||||||
|
|
||||||
|
If `source` looks like a path (or exists on disk), use it. Otherwise treat
|
||||||
|
it as a bundled app name and look up under /opt/furtka/apps/<name>.
|
||||||
|
"""
|
||||||
|
p = Path(source)
|
||||||
|
if p.is_dir():
|
||||||
|
return p
|
||||||
|
if "/" in source or source.startswith("."):
|
||||||
|
raise InstallError(f"{source!r} is not a directory")
|
||||||
|
bundled = bundled_apps_dir() / source
|
||||||
|
if bundled.is_dir():
|
||||||
|
return bundled
|
||||||
|
raise InstallError(f"{source!r} not found as a path or bundled app")
|
||||||
|
|
||||||
|
|
||||||
|
def install_from(src: Path) -> Path:
|
||||||
|
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/.
|
||||||
|
|
||||||
|
Preserves an existing .env on upgrade. Bootstraps .env from .env.example
|
||||||
|
on first install if .env wasn't shipped.
|
||||||
|
Returns the target folder.
|
||||||
|
"""
|
||||||
|
manifest_path = src / "manifest.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise InstallError(f"{src} has no manifest.json")
|
||||||
|
try:
|
||||||
|
m = load_manifest(manifest_path)
|
||||||
|
except ManifestError as e:
|
||||||
|
raise InstallError(str(e)) from e
|
||||||
|
|
||||||
|
target = apps_dir() / m.name
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for item in src.iterdir():
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
# Never overwrite an existing user .env.
|
||||||
|
if item.name == ".env" and (target / ".env").exists():
|
||||||
|
continue
|
||||||
|
shutil.copy2(item, target / item.name)
|
||||||
|
|
||||||
|
# First install with no .env shipped: bootstrap from .env.example so the
|
||||||
|
# user has something to edit and compose has values to substitute.
|
||||||
|
env = target / ".env"
|
||||||
|
env_example = target / ".env.example"
|
||||||
|
if not env.exists() and env_example.exists():
|
||||||
|
shutil.copy2(env_example, env)
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def remove(name: str) -> Path:
|
||||||
|
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
|
||||||
|
|
||||||
|
Caller is responsible for stopping the compose project first.
|
||||||
|
"""
|
||||||
|
target = apps_dir() / name
|
||||||
|
if not target.exists():
|
||||||
|
raise InstallError(f"{name!r} is not installed")
|
||||||
|
shutil.rmtree(target)
|
||||||
|
return target
|
||||||
35
furtka/reconciler.py
Normal file
35
furtka/reconciler.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from furtka import dockerops
|
||||||
|
from furtka.scanner import scan
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Action:
|
||||||
|
kind: str # "ensure_volume" | "compose_up" | "skip"
|
||||||
|
target: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if self.detail:
|
||||||
|
return f"{self.kind:14s} {self.target} ({self.detail})"
|
||||||
|
return f"{self.kind:14s} {self.target}"
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
|
||||||
|
actions: list[Action] = []
|
||||||
|
for result in scan(apps_root):
|
||||||
|
if not result.ok:
|
||||||
|
actions.append(Action("skip", result.path.name, result.error or ""))
|
||||||
|
continue
|
||||||
|
m = result.manifest
|
||||||
|
for vol_short in m.volumes:
|
||||||
|
full = m.volume_name(vol_short)
|
||||||
|
actions.append(Action("ensure_volume", full))
|
||||||
|
if not dry_run:
|
||||||
|
dockerops.ensure_volume(full)
|
||||||
|
actions.append(Action("compose_up", m.name))
|
||||||
|
if not dry_run:
|
||||||
|
dockerops.compose_up(result.path, m.name)
|
||||||
|
return actions
|
||||||
|
|
@ -51,4 +51,4 @@ def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
|
||||||
rc = main(["reconcile", "--dry-run"])
|
rc = main(["reconcile", "--dry-run"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "0 entries" in out
|
assert "0 actions" in out
|
||||||
|
|
|
||||||
118
tests/test_installer.py
Normal file
118
tests/test_installer.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
91
tests/test_reconciler.py
Normal file
91
tests/test_reconciler.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from furtka import dockerops, reconciler
|
||||||
|
|
||||||
|
VALID_MANIFEST = {
|
||||||
|
"name": "fileshare",
|
||||||
|
"display_name": "Network Files",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "SMB share",
|
||||||
|
"volumes": ["files", "config"],
|
||||||
|
"ports": [445],
|
||||||
|
"icon": "icon.svg",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_docker(monkeypatch):
|
||||||
|
"""Replace dockerops with no-op recorders so reconcile() doesn't shell out."""
|
||||||
|
calls: dict[str, list] = {"ensure_volume": [], "compose_up": [], "compose_down": []}
|
||||||
|
existing_volumes: set[str] = set()
|
||||||
|
|
||||||
|
def fake_ensure(name):
|
||||||
|
calls["ensure_volume"].append(name)
|
||||||
|
if name in existing_volumes:
|
||||||
|
return False
|
||||||
|
existing_volumes.add(name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fake_compose_up(app_dir, project):
|
||||||
|
calls["compose_up"].append((str(app_dir), project))
|
||||||
|
|
||||||
|
def fake_compose_down(app_dir, project):
|
||||||
|
calls["compose_down"].append((str(app_dir), project))
|
||||||
|
|
||||||
|
monkeypatch.setattr(dockerops, "ensure_volume", fake_ensure)
|
||||||
|
monkeypatch.setattr(dockerops, "compose_up", fake_compose_up)
|
||||||
|
monkeypatch.setattr(dockerops, "compose_down", fake_compose_down)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
(app / "docker-compose.yaml").write_text("services: {}\n")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_empty_root(tmp_path, fake_docker):
|
||||||
|
actions = reconciler.reconcile(tmp_path)
|
||||||
|
assert actions == []
|
||||||
|
assert fake_docker["ensure_volume"] == []
|
||||||
|
assert fake_docker["compose_up"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_one_app(tmp_path, fake_docker):
|
||||||
|
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||||
|
actions = reconciler.reconcile(tmp_path)
|
||||||
|
assert [(a.kind, a.target) for a in actions] == [
|
||||||
|
("ensure_volume", "furtka_fileshare_files"),
|
||||||
|
("ensure_volume", "furtka_fileshare_config"),
|
||||||
|
("compose_up", "fileshare"),
|
||||||
|
]
|
||||||
|
assert fake_docker["ensure_volume"] == [
|
||||||
|
"furtka_fileshare_files",
|
||||||
|
"furtka_fileshare_config",
|
||||||
|
]
|
||||||
|
assert len(fake_docker["compose_up"]) == 1
|
||||||
|
assert fake_docker["compose_up"][0][1] == "fileshare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_dry_run_does_not_act(tmp_path, fake_docker):
|
||||||
|
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||||
|
actions = reconciler.reconcile(tmp_path, dry_run=True)
|
||||||
|
assert len(actions) == 3
|
||||||
|
assert fake_docker["ensure_volume"] == []
|
||||||
|
assert fake_docker["compose_up"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_skips_broken_manifest(tmp_path, fake_docker):
|
||||||
|
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||||
|
_make_app(tmp_path, "broken") # no manifest
|
||||||
|
actions = reconciler.reconcile(tmp_path)
|
||||||
|
skip_actions = [a for a in actions if a.kind == "skip"]
|
||||||
|
assert len(skip_actions) == 1
|
||||||
|
assert skip_actions[0].target == "broken"
|
||||||
|
# Healthy app still got reconciled.
|
||||||
|
assert fake_docker["compose_up"] == [(str(tmp_path / "fileshare"), "fileshare")]
|
||||||
Loading…
Add table
Reference in a new issue