Compare commits

..

No commits in common. "main" and "26.17-alpha" have entirely different histories.

7 changed files with 10 additions and 82 deletions

View file

@ -7,25 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
## [26.18-alpha] - 2026-06-04
### 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 ## [26.17-alpha] - 2026-05-11
### Added ### Added

View file

@ -1,17 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from furtka import deps, dockerops, installer from furtka import deps, dockerops
from furtka.manifest import SETTING_NAME_RE, ManifestError, load_manifest from furtka.manifest import ManifestError, load_manifest
from furtka.scanner import scan from furtka.scanner import scan
_ON_START_TIMEOUT_SECONDS = 30.0 _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) @dataclass(frozen=True)
class Action: class Action:
@ -111,33 +106,13 @@ def _fire_on_start_hook(consumer, req, apps_root: Path) -> None:
req.app, req.app,
service, service,
hook_abs, hook_abs,
env=_on_start_hook_env(consumer, apps_root), env={
"FURTKA_CONSUMER_APP": consumer.name,
"FURTKA_CONSUMER_VERSION": consumer.version,
},
timeout=_ON_START_TIMEOUT_SECONDS, 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: def has_errors(actions: list[Action]) -> bool:
return any(a.kind == "error" for a in actions) return any(a.kind == "error" for a in actions)

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.18-alpha" version = "26.17-alpha"
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -196,34 +196,6 @@ def test_reconcile_fires_on_start_before_compose_up(tmp_path, fake_docker, monke
assert fake_docker["compose_up"][0][1] == "mosquitto" 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): def test_reconcile_on_start_failure_skips_consumer_compose_up(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir() (provider / "hooks").mkdir()

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle." description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.18-alpha</span> — in Arbeit" status: "<span class=\"mono\">26.16-alpha</span> — in Arbeit"
# features_today / features_next müssen index-parallel zu content/_index.md bleiben. # features_today / features_next müssen index-parallel zu content/_index.md bleiben.
intro: | intro: |
**Furtka** ist ein offenes Heimserver-Betriebssystem. **Furtka** ist ein offenes Heimserver-Betriebssystem.

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Open-source home server OS — simple enough for everyone." description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.18-alpha</span> — work in progress" status: "<span class=\"mono\">26.16-alpha</span> — work in progress"
# Keep features_today / features_next index-aligned with content/_index.de.md. # Keep features_today / features_next index-aligned with content/_index.de.md.
intro: | intro: |
**Furtka** is an open-source home server OS. **Furtka** is an open-source home server OS.

View file

@ -6,7 +6,7 @@ enableRobotsTXT = true
[params] [params]
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
version = "26.18-alpha" version = "26.16-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer] [markup.goldmark.renderer]