Compare commits

..

2 commits

Author SHA1 Message Date
1155f1d4ba chore: release 26.18-alpha
All checks were successful
Build ISO / build-iso (push) Successful in 18m44s
Deploy site / deploy (push) Successful in 4s
CI / lint (push) Successful in 28s
CI / test (push) Successful in 1m28s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 14s
Release / release (push) Successful in 12m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:38:54 +02:00
1a2b817eb8 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
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>
2026-05-28 23:45:42 +02:00
7 changed files with 82 additions and 10 deletions

View file

@ -7,6 +7,25 @@ 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,12 +1,17 @@
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from furtka import deps, dockerops from furtka import deps, dockerops, installer
from furtka.manifest import ManifestError, load_manifest from furtka.manifest import SETTING_NAME_RE, 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:
@ -106,13 +111,33 @@ def _fire_on_start_hook(consumer, req, apps_root: Path) -> None:
req.app, req.app,
service, service,
hook_abs, hook_abs,
env={ env=_on_start_hook_env(consumer, apps_root),
"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.17-alpha" version = "26.18-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,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" 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.16-alpha</span> — in Arbeit" status: "<span class=\"mono\">26.18-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.16-alpha</span> — work in progress" status: "<span class=\"mono\">26.18-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.16-alpha" version = "26.18-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer] [markup.goldmark.renderer]