From 0615d92c8db84d56f2f0764478e25c10c2459d84 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Sun, 28 Jun 2026 13:03:33 +0200 Subject: [PATCH] fix(install): copy app subdirectories so dependency hooks ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//. 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) --- furtka/installer.py | 11 ++++++++++- tests/test_installer.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/furtka/installer.py b/furtka/installer.py index 246d6e1..f5ec1ae 100644 --- a/furtka/installer.py +++ b/furtka/installer.py @@ -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" diff --git a/tests/test_installer.py b/tests/test_installer.py index 22b171f..f784957 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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"