fix(install): copy app subdirectories so dependency hooks ship

install_from() only copied top-level files, so an app's scripts/ folder —
where provider on_install/on_start dependency hooks live — never reached
/var/lib/furtka/apps/<app>/. Every hooked dependency therefore failed at
reconcile with "hook ... missing in provider". Copy subdirectories too
(rmtree+copytree so a reinstall drops files removed upstream).

Found during the first real end-to-end run of the mosquitto+zigbee2mqtt
dependency pair on a test VM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-06-28 13:03:33 +02:00
parent 1155f1d4ba
commit 0615d92c8d
2 changed files with 35 additions and 1 deletions

View file

@ -171,13 +171,22 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
target.mkdir(parents=True, exist_ok=True)
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():
continue
# Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists():
continue
shutil.copy2(item, target / item.name)
shutil.copy2(item, dest)
env = target / ".env"
env_example = target / ".env.example"

View file

@ -73,6 +73,31 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
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):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
target = apps_dir() / "fileshare"