fix(deps): give on_start hooks the consumer's stored credentials
All checks were successful
CI / lint (pull_request) Successful in 31s
CI / test (pull_request) Successful in 1m27s
CI / validate-json (pull_request) Successful in 25s
CI / markdown-links (pull_request) Successful in 54s
Build ISO / build-iso (push) Successful in 18m35s
CI / lint (push) Successful in 29s
CI / test (push) Successful in 1m20s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 54s
All checks were successful
CI / lint (pull_request) Successful in 31s
CI / test (pull_request) Successful in 1m27s
CI / validate-json (pull_request) Successful in 25s
CI / markdown-links (pull_request) Successful in 54s
Build ISO / build-iso (push) Successful in 18m35s
CI / lint (push) Successful in 29s
CI / test (push) Successful in 1m20s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 54s
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_<KEY> (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) <noreply@anthropic.com>
This commit is contained in:
parent
b725bf1773
commit
1a2b817eb8
3 changed files with 76 additions and 6 deletions
17
CHANGELOG.md
17
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_<KEY>` (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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue