Compare commits
No commits in common. "bf36b7621b2dd8f22b23be7762a23d2ff2fd942f" and "1155f1d4bad98e87db0a7202767c2358cd5e47c5" have entirely different histories.
bf36b7621b
...
1155f1d4ba
5 changed files with 26 additions and 115 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -7,26 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **App installs now copy subdirectories, so dependency hooks actually
|
|
||||||
ship.** `install_from()` only copied an app's top-level files, so the
|
|
||||||
`scripts/` folder — where a provider's `on_install`/`on_start` dependency
|
|
||||||
hooks live — never reached `/var/lib/furtka/apps/<app>/`, and every hooked
|
|
||||||
dependency failed at reconcile with `hook ... missing in provider`. The
|
|
||||||
whole app folder is copied now (`rmtree` + `copytree`, so a reinstall also
|
|
||||||
drops files that were removed upstream). Found during the first real
|
|
||||||
end-to-end run of the mosquitto + zigbee2mqtt pair on a test VM.
|
|
||||||
- **`furtka app install <name>` now runs `on_install` hooks.** Named CLI
|
|
||||||
installs copied the app folders and then ran a bare reconcile, which fires
|
|
||||||
`on_start` but never `on_install` — so a CLI install of a consumer brought
|
|
||||||
its provider up without ever provisioning it (no account created, empty
|
|
||||||
`MQTT_*` values in the consumer's `.env`). Named installs now go through
|
|
||||||
`install_runner.run_install` (writing the plan file first), the same docker
|
|
||||||
phase the API dispatches, so providers come up before consumers and
|
|
||||||
`on_install` hooks run. Path-based installs keep the copy + reconcile
|
|
||||||
dev/test path.
|
|
||||||
|
|
||||||
## [26.18-alpha] - 2026-06-04
|
## [26.18-alpha] - 2026-06-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -69,62 +69,37 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
|
||||||
def _cmd_app_install(args: argparse.Namespace) -> int:
|
def _cmd_app_install(args: argparse.Namespace) -> int:
|
||||||
# If the user passed a path (or a path-ish thing), bypass dep resolution —
|
# If the user passed a path (or a path-ish thing), bypass dep resolution —
|
||||||
# local paths are dev/test workflows where the caller knows what they want.
|
# local paths are dev/test workflows where the caller knows what they want.
|
||||||
# These just copy + reconcile; no `requires`/hook handling.
|
# Catalog/bundled name installs go through plan_install() so transitive
|
||||||
|
# `requires` are pulled in.
|
||||||
src_path = Path(args.source)
|
src_path = Path(args.source)
|
||||||
is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".")
|
is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".")
|
||||||
if is_path:
|
try:
|
||||||
try:
|
if is_path:
|
||||||
src = installer.resolve_source(args.source)
|
src = installer.resolve_source(args.source)
|
||||||
target = installer.install_from(src)
|
target = installer.install_from(src)
|
||||||
except installer.InstallError as e:
|
print(f"installed {target.name} to {target}")
|
||||||
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 1 if reconciler.has_errors(actions) else 0
|
|
||||||
|
|
||||||
# Catalog/bundled name install: resolve transitive `requires`, copy every
|
|
||||||
# app folder (the synchronous phase the API runs inline), then drive the
|
|
||||||
# exact same docker phase the API dispatches via systemd-run — so providers
|
|
||||||
# come up before consumers and `on_install` hooks fire to provision them.
|
|
||||||
# Going through run_install (not a bare reconcile) is what makes a CLI
|
|
||||||
# install of a consumer actually provision against its provider.
|
|
||||||
from furtka import install_runner
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = deps.plan_install(args.source)
|
|
||||||
except deps.DependencyError as e:
|
|
||||||
print(f"error: {e}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
if plan.to_install:
|
|
||||||
installer.install_plan(plan)
|
|
||||||
to_install = list(plan.to_install)
|
|
||||||
else:
|
else:
|
||||||
# Target already installed — reinstall to refresh files, then still
|
try:
|
||||||
# run the docker phase so hooks re-fire (matches API reinstall).
|
plan = deps.plan_install(args.source)
|
||||||
installer.install_from(installer.resolve_source(args.source))
|
except deps.DependencyError as e:
|
||||||
to_install = [args.source]
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not plan.to_install:
|
||||||
|
# Target is already installed — re-run as a single-app install
|
||||||
|
# to refresh files (matches reinstall semantics).
|
||||||
|
target_path = installer.install_from(installer.resolve_source(args.source))
|
||||||
|
print(f"reinstalled {target_path.name} to {target_path}")
|
||||||
|
else:
|
||||||
|
targets = installer.install_plan(plan)
|
||||||
|
for t in targets:
|
||||||
|
print(f"installed {t.name} to {t}")
|
||||||
except installer.InstallError as e:
|
except installer.InstallError as e:
|
||||||
print(f"error: {e}", file=sys.stderr)
|
print(f"error: {e}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
actions = reconciler.reconcile(apps_dir())
|
||||||
# Stage the plan file run_install consumes (it removes it after reading).
|
for a in actions:
|
||||||
install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
|
print(f" {a.describe()}")
|
||||||
install_runner.plan_path().write_text(
|
return 1 if reconciler.has_errors(actions) else 0
|
||||||
json.dumps({"target": args.source, "to_install": to_install})
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
install_runner.run_install(args.source)
|
|
||||||
except Exception as e:
|
|
||||||
# run_install already wrote state="error"; surface it to the caller.
|
|
||||||
print(f"error: {e}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
for name in to_install:
|
|
||||||
print(f"installed {name}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_app_install_bg(args: argparse.Namespace) -> int:
|
def _cmd_app_install_bg(args: argparse.Namespace) -> int:
|
||||||
|
|
|
||||||
|
|
@ -171,22 +171,13 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for item in src.iterdir():
|
for item in src.iterdir():
|
||||||
dest = target / item.name
|
|
||||||
# Subdirectories (e.g. scripts/ holding a provider's on_install/on_start
|
|
||||||
# dependency hooks) must come along too — copy the whole tree, replacing
|
|
||||||
# any stale copy from a previous install so removed files don't linger.
|
|
||||||
if item.is_dir():
|
|
||||||
if dest.exists():
|
|
||||||
shutil.rmtree(dest)
|
|
||||||
shutil.copytree(item, dest)
|
|
||||||
continue
|
|
||||||
if not item.is_file():
|
if not item.is_file():
|
||||||
continue
|
continue
|
||||||
# Never overwrite an existing user .env — either settings-driven write
|
# Never overwrite an existing user .env — either settings-driven write
|
||||||
# or previous manual edit has authority.
|
# or previous manual edit has authority.
|
||||||
if item.name == ".env" and (target / ".env").exists():
|
if item.name == ".env" and (target / ".env").exists():
|
||||||
continue
|
continue
|
||||||
shutil.copy2(item, dest)
|
shutil.copy2(item, target / item.name)
|
||||||
|
|
||||||
env = target / ".env"
|
env = target / ".env"
|
||||||
env_example = target / ".env.example"
|
env_example = target / ".env.example"
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
|
||||||
_write_manifest(bundled, "mosquitto")
|
_write_manifest(bundled, "mosquitto")
|
||||||
_write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
|
_write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
|
||||||
|
|
||||||
from furtka import install_runner, installer
|
from furtka import installer, reconciler
|
||||||
|
|
||||||
# Stub install_from so we don't actually copy files / mess with placeholders.
|
# Stub install_from so we don't actually copy files / mess with placeholders.
|
||||||
install_calls: list[str] = []
|
install_calls: list[str] = []
|
||||||
|
|
@ -168,22 +168,12 @@ def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
|
||||||
return tmp_path / src.name
|
return tmp_path / src.name
|
||||||
|
|
||||||
monkeypatch.setattr(installer, "install_from", fake_install_from)
|
monkeypatch.setattr(installer, "install_from", fake_install_from)
|
||||||
monkeypatch.setattr(install_runner, "_INSTALL_PLAN", tmp_path / "install-plan.json")
|
monkeypatch.setattr(reconciler, "reconcile", lambda *a, **k: [])
|
||||||
# Named installs drive the docker phase through run_install (so on_install
|
|
||||||
# hooks fire) — stub it and assert it's invoked with the chosen target.
|
|
||||||
run_calls: list[str] = []
|
|
||||||
monkeypatch.setattr(install_runner, "run_install", lambda name: run_calls.append(name))
|
|
||||||
|
|
||||||
rc = main(["app", "install", "zigbee2mqtt"])
|
rc = main(["app", "install", "zigbee2mqtt"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
# Provider installed before consumer.
|
# Provider installed before consumer.
|
||||||
assert install_calls == ["mosquitto", "zigbee2mqtt"]
|
assert install_calls == ["mosquitto", "zigbee2mqtt"]
|
||||||
# The docker phase (which fires on_install hooks) ran for the target.
|
|
||||||
assert run_calls == ["zigbee2mqtt"]
|
|
||||||
# And the plan file was staged for run_install to consume.
|
|
||||||
plan = json.loads(install_runner.plan_path().read_text())
|
|
||||||
assert plan["target"] == "zigbee2mqtt"
|
|
||||||
assert plan["to_install"] == ["mosquitto", "zigbee2mqtt"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys):
|
def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys):
|
||||||
|
|
|
||||||
|
|
@ -73,31 +73,6 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
|
||||||
assert (target / ".env").read_text() == "A=1"
|
assert (target / ".env").read_text() == "A=1"
|
||||||
|
|
||||||
|
|
||||||
def test_install_from_copies_subdirectories(tmp_path, fake_dirs):
|
|
||||||
# Dependency hooks live under scripts/ — the whole subtree must be copied,
|
|
||||||
# not just top-level files, or providers ship without their hooks.
|
|
||||||
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
|
|
||||||
scripts = src / "scripts"
|
|
||||||
scripts.mkdir()
|
|
||||||
(scripts / "provision-client.sh").write_text("#!/bin/sh\necho hi\n")
|
|
||||||
target = installer.install_from(src)
|
|
||||||
assert (target / "scripts" / "provision-client.sh").read_text() == "#!/bin/sh\necho hi\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_from_reinstall_drops_stale_subdir_files(tmp_path, fake_dirs):
|
|
||||||
# A reinstall whose source renamed/removed a hook must not leave the old one.
|
|
||||||
src = _write_app_source(tmp_path, "mosquitto", VALID_MANIFEST, env_example="A=1")
|
|
||||||
scripts = src / "scripts"
|
|
||||||
scripts.mkdir()
|
|
||||||
(scripts / "old-hook.sh").write_text("old\n")
|
|
||||||
installer.install_from(src)
|
|
||||||
(scripts / "old-hook.sh").unlink()
|
|
||||||
(scripts / "new-hook.sh").write_text("new\n")
|
|
||||||
target = installer.install_from(src)
|
|
||||||
assert not (target / "scripts" / "old-hook.sh").exists()
|
|
||||||
assert (target / "scripts" / "new-hook.sh").read_text() == "new\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
|
def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
|
||||||
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
|
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
|
||||||
target = apps_dir() / "fileshare"
|
target = apps_dir() / "fileshare"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue