From 1a2b817eb85a8a4657f280b2ec9db8321f86c6dd Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Thu, 28 May 2026 23:45:42 +0200 Subject: [PATCH] fix(deps): give on_start hooks the consumer's stored credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconciler handed an on_start dependency hook only FURTKA_CONSUMER_APP/_VERSION, so it had no way to learn the consumer's existing secrets. That made the feature's own headline use case — re-create a provider account (e.g. an MQTT user) after a wipe with the same password the consumer already holds — impossible unless the provider stashed a copy itself. on_start now also receives the consumer's .env values, namespaced under FURTKA_CONSUMER_ENV_ (UPPER_SNAKE_CASE keys only, so a hand-edited .env can't produce a malformed --env arg). on_start stays read-only toward the consumer: unlike on_install, its stdout is intentionally not merged back into the consumer's .env. Surfaced building the first real provider/consumer catalog pair (mosquitto + zigbee2mqtt) in daniel/furtka-apps. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 +++++++++++++++++ furtka/reconciler.py | 37 +++++++++++++++++++++++++++++++------ tests/test_reconciler.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4619b4..fa0f414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +### Fixed + +- **`on_start` dependency hooks now receive the consumer's stored + credentials.** Previously the reconciler handed an `on_start` hook only + `FURTKA_CONSUMER_APP`/`FURTKA_CONSUMER_VERSION`, so it had no way to learn + the consumer's existing secrets — which made the feature's own headline use + case (re-create a provider account, e.g. an MQTT user, after a wipe, with + the *same* password the consumer already holds) impossible without the + provider stashing a copy itself. The hook now also gets the consumer's `.env` + values, namespaced under `FURTKA_CONSUMER_ENV_` (only UPPER_SNAKE_CASE + keys, so a hand-edited `.env` can't produce a malformed `--env` argument). + `on_start` stays read-only with respect to the consumer: unlike `on_install`, + its stdout is intentionally not merged back into the consumer's `.env` — it + reads consumer state to reconcile provider state, it doesn't mutate it. + Surfaced by building the first real provider/consumer catalog pair + (mosquitto + zigbee2mqtt) in daniel/furtka-apps. + ## [26.17-alpha] - 2026-05-11 ### Added diff --git a/furtka/reconciler.py b/furtka/reconciler.py index 2f8278a..4197e4b 100644 --- a/furtka/reconciler.py +++ b/furtka/reconciler.py @@ -1,12 +1,17 @@ from dataclasses import dataclass from pathlib import Path -from furtka import deps, dockerops -from furtka.manifest import ManifestError, load_manifest +from furtka import deps, dockerops, installer +from furtka.manifest import SETTING_NAME_RE, ManifestError, load_manifest from furtka.scanner import scan _ON_START_TIMEOUT_SECONDS = 30.0 +# Consumer .env values are exported into an on_start hook's environment under +# this prefix. Namespaced so a consumer setting named PATH/HOME/etc. can't +# clobber the provider container's own environment when the hook runs. +_CONSUMER_ENV_PREFIX = "FURTKA_CONSUMER_ENV_" + @dataclass(frozen=True) class Action: @@ -106,13 +111,33 @@ def _fire_on_start_hook(consumer, req, apps_root: Path) -> None: req.app, service, hook_abs, - env={ - "FURTKA_CONSUMER_APP": consumer.name, - "FURTKA_CONSUMER_VERSION": consumer.version, - }, + env=_on_start_hook_env(consumer, apps_root), timeout=_ON_START_TIMEOUT_SECONDS, ) +def _on_start_hook_env(consumer, apps_root: Path) -> dict[str, str]: + """Build the environment handed to an on_start hook. + + Beyond the consumer's name/version, this injects the consumer's stored + `.env` values (namespaced under `FURTKA_CONSUMER_ENV_`) so the hook can + re-establish provider-side state *idempotently* — e.g. re-create an MQTT + account with the SAME password the consumer already holds. The reconciler + does not merge an on_start hook's stdout back into the consumer's `.env` + (unlike on_install): on_start reads consumer state, it doesn't mutate it. + Only UPPER_SNAKE_CASE keys are injected so a hand-edited `.env` can't + produce a malformed `docker compose exec --env` argument. + """ + env = { + "FURTKA_CONSUMER_APP": consumer.name, + "FURTKA_CONSUMER_VERSION": consumer.version, + } + consumer_env = installer.read_env_values(apps_root / consumer.name / ".env") + for key, value in consumer_env.items(): + if SETTING_NAME_RE.match(key): + env[f"{_CONSUMER_ENV_PREFIX}{key}"] = value + return env + + def has_errors(actions: list[Action]) -> bool: return any(a.kind == "error" for a in actions) diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index d5575cf..2177645 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -196,6 +196,34 @@ def test_reconcile_fires_on_start_before_compose_up(tmp_path, fake_docker, monke assert fake_docker["compose_up"][0][1] == "mosquitto" +def test_reconcile_on_start_hook_receives_consumer_env(tmp_path, fake_docker, monkeypatch): + # The on_start hook must be able to re-establish provider state with the + # SAME credentials the consumer already holds, so the consumer's stored + # .env is injected (namespaced) alongside FURTKA_CONSUMER_APP/_VERSION. + provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) + (provider / "hooks").mkdir() + (provider / "hooks" / "ensure-user.sh").write_bytes(b"#!/bin/sh\n") + consumer = _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST) + # A real, stored credential plus a junk lowercase key that must be dropped. + (consumer / ".env").write_text("MQTT_PASS=s3cret-from-install\nnot_a_setting=x\n") + + seen_env: dict[str, str] = {} + + def fake_exec_script(app_dir, project, service, script_path, *, env, timeout): + seen_env.update(env) + return "" + + monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"}) + monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script) + + reconciler.reconcile(tmp_path) + + assert seen_env["FURTKA_CONSUMER_APP"] == "zigbee2mqtt" + assert seen_env["FURTKA_CONSUMER_ENV_MQTT_PASS"] == "s3cret-from-install" + # Lowercase / non-UPPER_SNAKE keys are not exported. + assert not any(k.endswith("not_a_setting") for k in seen_env) + + def test_reconcile_on_start_failure_skips_consumer_compose_up(tmp_path, fake_docker, monkeypatch): provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) (provider / "hooks").mkdir()