Compare commits

...

22 commits

Author SHA1 Message Date
722dac4843 feat(website): randomise which logo comes first on /logo/ per visit
Some checks failed
CI / lint (push) Failing after 29s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Successful in 14s
Deploy site / deploy (push) Successful in 4s
CI / test (push) Successful in 1m29s
Hugo sorts map keys, so `range (dict "m" … "r" …)` always put M first
and gave it the "first thing on the page" vote. The template now ranges
over an ordered list (front-matter `order:` can pin it), and a tiny
inline script on poll pages coin-flips the visual order before first
paint, applied via CSS `order` on every M/R pair and remembered in
sessionStorage so a reload or a vote doesn't shuffle the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8kPxizsExc95ZhvfJmaCw
2026-08-27 12:43:46 +02:00
c3c59b4bea feat(website): share card for /logo/ (og:image) so links to the vote get a preview
Some checks failed
CI / lint (push) Failing after 26s
CI / validate-json (push) Successful in 22s
CI / markdown-links (push) Successful in 14s
Deploy site / deploy (push) Successful in 4s
CI / test (push) Successful in 1m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 14:33:28 +02:00
58a99a0d9e fix(website): sharper console banners on /logo/ (30 columns, coverage-sampled), no line gaps or phone text inflation
Some checks failed
CI / lint (push) Failing after 26s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Successful in 14s
Deploy site / deploy (push) Successful in 4s
CI / test (push) Successful in 1m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 14:04:47 +02:00
22ce03d1f0 fix(website): build on Hugo 0.147 again (site.Data), chown site trees even when the CI build fails
Some checks failed
CI / lint (push) Failing after 31s
CI / validate-json (push) Successful in 22s
CI / test (push) Successful in 1m23s
Deploy site / deploy (push) Successful in 5s
CI / markdown-links (push) Successful in 14s
hugo.Data is not available in the alpine Hugo the deploy job uses; the
failed render left the webroot half-cleaned and root-owned because the
chown only ran after a successful build. It is an EXIT trap now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 13:35:40 +02:00
5d827e5eab feat(website): logo showcase on /logo/, poll link + current feature list on the homepage
Some checks failed
CI / lint (push) Failing after 25s
CI / test (push) Successful in 1m28s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 14s
Deploy site / deploy (push) Failing after 5s
Both marks now appear everywhere they would have to work: five sizes
down to the real 16 px tab icon, horizontal and stacked lockups, browser
tab + site header, phone tiles, console banner + boot splash, social
card, and four colour grounds — generated from one geometry source by
website/tools/logo-assets.py (outputs committed under data/logo and
static/img/logo). The homepage links to the vote and its 'what works
today' list catches up with 26.8–26.17: password login, online app
catalog, app dependencies, opt-in HTTPS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 13:30:10 +02:00
aaba14731c fix(poll): resolve the client address via nginx real_ip, never from X-Forwarded-For
All checks were successful
CI / lint (push) Successful in 33s
CI / test (push) Successful in 2m7s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 16s
A client-supplied X-Forwarded-For could mint a fresh device hash per
request. nginx now trusts only the edge hop (192.168.178.164) when
unwinding the chain and passes the result as X-Real-IP; the service
uses nothing else. setup-poll.sh --reset wipes the vote table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 13:21:19 +02:00
9fd9d1b7d1 ci: stop ops/** from triggering the ISO build; hand site trees back to the VM user after CI deploy
All checks were successful
CI / lint (push) Successful in 25s
CI / test (push) Successful in 1m38s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Deploy site / deploy (push) Successful in 4s
Build ISO / build-iso (push) Successful in 18m59s
The runner job runs as root, which left /srv/furtka-site and
/var/www/furtka.org root-owned and broke the manual deploy.sh path.
Runner capacity on forge-runner-01 raised to 2 alongside this, so the
site deploy no longer queues behind an ISO build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 13:12:16 +02:00
2fbafd5c77 feat(website): logo poll at /logo/ with cookie-free vote counter
Some checks failed
CI / test (push) Waiting to run
Build ISO / build-iso (push) Successful in 18m1s
CI / lint (push) Successful in 24s
CI / markdown-links (push) Successful in 18s
CI / validate-json (push) Successful in 21s
Deploy site / deploy (push) Has been cancelled
Two marks (M and R) side by side, EN + DE, with the site-header and real
16 px favicon context for each. Votes go to ops/poll/pollsvc.py, a stdlib
Python service behind nginx at /api/poll/ (SQLite, systemd DynamicUser,
rate-limited). Devices are counted once via a salted hash of IP + user
agent; the privacy pages document it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 12:57:23 +02:00
8b07b57242 fix(updater): start newly linked timers and link units via current; bench test report
All checks were successful
CI / lint (pull_request) Successful in 24s
CI / test (pull_request) Successful in 1m18s
CI / validate-json (pull_request) Successful in 22s
CI / markdown-links (pull_request) Successful in 14s
After the 26.19 → 26.20 self-update the bench had furtka-welcome.timer
linked + enabled but inactive (dead): enable only arms a timer for the
next boot. The link also pointed at /opt/furtka/versions/<ver>/ instead
of /opt/furtka/current/, unlike the installer-created links, so it would
have stopped following later updates and rollbacks. Link via current,
enable, start.

Adds docs/hardware-test-2026-08.md (timeline, five findings with root
causes / fixes / validation status, what worked, open items) and a
'Real hardware' section in iso/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-26 09:46:13 +02:00
8e281fa4a6 chore: release 26.20-alpha
All checks were successful
CI / lint (pull_request) Successful in 24s
CI / test (pull_request) Successful in 1m19s
CI / validate-json (pull_request) Successful in 21s
CI / markdown-links (pull_request) Successful in 14s
Build ISO / build-iso (push) Successful in 18m15s
Deploy site / deploy (push) Successful in 4s
CI / lint (push) Successful in 23s
CI / test (push) Successful in 1m17s
CI / validate-json (push) Successful in 21s
CI / markdown-links (push) Successful in 13s
Release / release (push) Successful in 12m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 17:08:25 +02:00
32ba522d59 fix(catalog): retry the boot-time sync and record failures as an error stage
On dual-stack LANs network-online.target is satisfied by the IPv6 SLAAC
address about a second before DHCPv4 lands, so the first
furtka-catalog-sync run died with 'Network is unreachable' and the next
attempt was the daily timer (OnBootSec=10min + up to 6 h jitter). The
state file stayed on 'checking' and the apps page showed 'checking…'
with nothing running — first hardware bench, 2026-08-25.

- furtka-catalog-sync.service: Restart=on-failure every 20 s, up to 8
  times in 15 min
- sync_catalog() writes stage 'error' + message on CatalogError, then
  re-raises (body moved to _sync_locked())
- apps page shows 'last sync failed: <error>' for that stage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 16:38:06 +02:00
60404ae643 fix(installer): put the 'pull the USB stick' step before the restart button
The hint lived on the rebooting page and in a confirm() popup — i.e. after
the click, with the machine restarting 3 s later. On the first hardware
bench the box promptly booted the stick again. The done state now shows a
two-step list (pull the stick → restart) above the button, and the button
says what it assumes: 'USB stick is out — restart now'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 16:25:25 +02:00
5ddda5302a fix(installer): release the target disk before archinstall partitions it
The live ISO auto-activates LVM/md/swap found on attached drives. The
first hardware bench's SSD still carried a Proxmox VE 'pve' VG, so six
device-mapper nodes sat on sda3 and archinstall died at 12 % with
'unable to inform the kernel of the change ... in use'.

install_run now calls diskprep.release_disk() first: swapoff/umount
leaves-first, dmsetup remove / mdadm --stop for every dm/md node stacked
on the disk (retrying until the stack is gone), wipefs -a on partitions
and disk, blockdev --rereadpt. Steps are logged at the top of the install
log (spawn_archinstall opens it in append mode now). The progress page
names this failure explicitly if it still happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 15:18:39 +02:00
0e05bef667 fix(console): always show the IP fallback on the welcome banner
The /etc/issue banner on both the live ISO and the installed system was a
oneshot after network-online.target that simply dropped the http://<ip>
line when DHCP hadn't delivered yet — so a slow NIC (first hardware bench,
2026-08-24) left the screen offering only proksi.local. On the ISO,
releng's root autologin on tty1 also made agetty --reload a no-op.

- furtka-issue.timer (ISO) / furtka-welcome.timer (target) re-run the
  banner script every 5 s; services drop RemainAfterExit + network wait
- fallback line is always present: every global IPv4, or a 'no IP yet —
  check cable / DHCP' hint; only rewrites + reloads on change
- build.sh removes releng's getty@tty1 autologin drop-in; banner tells
  users a shell is root + Enter away

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 15:18:39 +02:00
e1968facef chore: release 26.19-alpha
All checks were successful
Build ISO / build-iso (push) Successful in 19m56s
Deploy site / deploy (push) Successful in 4s
CI / lint (push) Successful in 23s
CI / test (push) Successful in 1m18s
CI / validate-json (push) Successful in 21s
CI / markdown-links (push) Successful in 13s
Release / release (push) Successful in 12m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFapVxVPvJJXwngkdbndxn
2026-08-23 16:05:43 +02:00
bf36b7621b docs: changelog entries for the dependency-hook install fixes
Some checks failed
CI / lint (pull_request) Successful in 33s
CI / test (pull_request) Successful in 1m20s
CI / validate-json (pull_request) Successful in 23s
CI / markdown-links (pull_request) Successful in 15s
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:29:08 +02:00
c2c3cdb1ed fix(cli): fire on_install hooks on named CLI installs
`furtka app install <name>` copied app folders (install_plan) then ran a
bare reconcile, which fires on_start but never on_install — so a CLI
install of a consumer brought its provider up without ever provisioning
it (no account, empty MQTT_* in the consumer .env). Route named installs
through install_runner.run_install (writing the plan file first), the same
docker phase the API dispatches, so providers come up before consumers and
on_install hooks run. Path installs keep the copy+reconcile dev/test path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:03:33 +02:00
0615d92c8d fix(install): copy app subdirectories so dependency hooks ship
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/<app>/. 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) <noreply@anthropic.com>
2026-06-28 13:03:33 +02:00
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
b725bf1773 chore: ruff format
All checks were successful
Build ISO / build-iso (push) Successful in 18m3s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m22s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 13s
Release / release (push) Successful in 12m13s
Whitespace-only — `ruff check` was green when 26.17-alpha shipped but
I forgot to run `ruff format`, so the CI format-check job went red on
the release commit. Runtime artifacts are unaffected (release.yml
doesn't gate on lint); this just re-greens the main baseline going
forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:10:04 +02:00
8e1f817d85 feat(apps): app-to-app dependencies with install + start hooks
Some checks failed
Build ISO / build-iso (push) Successful in 21m39s
CI / lint (push) Failing after 28s
CI / test (push) Successful in 1m29s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 14s
Release / release (push) Successful in 12m2s
Manifests gain an optional `requires` array. Each entry points at
another app and may declare `on_install` + `on_start` hook scripts
that live in the *provider's* folder and run inside its container
via `docker compose exec`. Hook stdout (KEY=VALUE + optional
FURTKA_JSON: sentinel) gets merged into the consumer's .env; the
placeholder-secret check re-runs over the merged file. Provider apps
that aren't installed get auto-installed first (topo order, cycle
detection, explicit UI confirm). Removing an app is blocked while
other installed apps require it. Reconcile now visits apps in
dependency order so consumers' on_start hooks fire against already-up
providers; per-app error isolation skips just the offending consumer's
compose_up.

Release 26.17-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:39:10 +02:00
80 changed files with 4111 additions and 154 deletions

View file

@ -12,6 +12,7 @@ on:
- '**/*.md' - '**/*.md'
- 'docs/**' - 'docs/**'
- 'website/**' - 'website/**'
- 'ops/**'
- 'CHANGELOG.md' - 'CHANGELOG.md'
- 'RELEASING.md' - 'RELEASING.md'
workflow_dispatch: workflow_dispatch:

View file

@ -7,6 +7,175 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
### Fixed
- **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release
introduced but never started it, so after the 26.19 → 26.20 update the
bench sat with `furtka-welcome.timer` `inactive (dead)` — and the console
banner without its IP line — until the next reboot. It also linked the
unit from `/opt/furtka/versions/<ver>/…` instead of `/opt/furtka/current/…`,
so the link would have stopped following later updates and rollbacks
(installer-created links use `current`). Now: link via `current`, enable,
start.
## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
SSD with a previous Proxmox VE on it). All four fixes are things an empty
Proxmox VM never shows.
### Fixed
- **First catalog sync no longer dies silently at boot.** On dual-stack
LANs `network-online.target` is satisfied by the IPv6 SLAAC address a
second before DHCPv4 lands, so `furtka-catalog-sync.service` ran into
`Network is unreachable`, exited, and the next attempt was the daily timer
— up to 6 h of jitter later. Meanwhile `catalog-state.json` stayed on
`checking` and the apps page showed "checking…" with nothing running
(first hardware bench, 2026-08-25). The service now retries on failure
(every 20 s, up to 8 times in 15 min), a failed sync is recorded as stage
`error` with the message, and the apps page shows "last sync failed: …"
instead of a fake in-progress state.
- **Installer frees the target disk before partitioning.** The live ISO
auto-activates whatever an attached drive carries (LVM volume groups, md
arrays, swap) — the first hardware bench's SSD still had a Proxmox VE
`pve` VG on it — and archinstall then failed at 12 % with `unable to
inform the kernel of the change, probably because it/they are in use`.
`install_run` now runs `diskprep.release_disk()` first: swapoff/umount
leaves-first, `dmsetup remove` / `mdadm --stop` for every dm/md node
stacked on the disk, `wipefs -a` on partitions + disk, then
`blockdev --rereadpt`. Every step is written to the top of the install
log. The progress page also names this failure explicitly instead of a
bare "hit a snag" if it does still happen.
- **Console banner always shows the IP fallback.** On both the live ISO and
the installed system the `/etc/issue` welcome was written once, after
`network-online.target`, and simply omitted the `http://<ip>` line when no
address existed yet — so on a slow-DHCP NIC (first hardware bench,
2026-08-24) the screen only ever offered `proksi.local`. The banner script
now runs from a timer (`furtka-issue.timer` on the ISO,
`furtka-welcome.timer` on the installed box, every 5 s) and always prints
a fallback line: every global IPv4 address once there is one, or a
"no IP address yet — check the cable / DHCP" hint until then. It only
rewrites `/etc/issue` and nudges agetty when the text changed, so the
login prompt doesn't flicker. The live ISO no longer auto-logs `root` in
on tty1 (releng default) — that handed the console to a shell before the
banner could update; tty1 now stays at the login prompt with a live
banner, and the banner tells you a shell is `root` + Enter away. Boxes
that self-update get the new timer linked + enabled automatically; the
refresh loop takes effect after their next reboot.
## [26.19-alpha] - 2026-08-23
### Fixed
- **App installs now copy subdirectories, so dependency hooks actually
ship.** `install_from()` only copied an app's top-level files, so the
`scripts/` folder — where a provider's `on_install`/`on_start` dependency
hooks live — never reached `/var/lib/furtka/apps/<app>/`, and every hooked
dependency failed at reconcile with `hook ... missing in provider`. The
whole app folder is copied now (`rmtree` + `copytree`, so a reinstall also
drops files that were removed upstream). Found during the first real
end-to-end run of the mosquitto + zigbee2mqtt pair on a test VM.
- **`furtka app install <name>` now runs `on_install` hooks.** Named CLI
installs copied the app folders and then ran a bare reconcile, which fires
`on_start` but never `on_install` — so a CLI install of a consumer brought
its provider up without ever provisioning it (no account created, empty
`MQTT_*` values in the consumer's `.env`). Named installs now go through
`install_runner.run_install` (writing the plan file first), the same docker
phase the API dispatches, so providers come up before consumers and
`on_install` hooks run. Path-based installs keep the copy + reconcile
dev/test path.
## [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
### Added
- **App-to-app dependencies.** Manifests gain an optional `requires`
array; each entry names a provider app plus two optional hook scripts
that live in the *provider's* folder. `on_install` runs once via
`docker compose exec` against the provider's running container while
the consumer is being installed (use case: `mosquitto_passwd` a new
MQTT user for the consumer). `on_start` runs every boot during
reconcile, before the consumer's container starts (use case: make
sure the user still exists after a Mosquitto wipe). Hook stdout
parses as `KEY=VALUE` lines and optional `FURTKA_JSON: {…}` sentinel
lines, both validated against the existing `SETTING_NAME` regex; the
values get merged into the consumer's `.env` (hook wins on conflict)
and the placeholder-secret check runs again over the merged file so
a hook returning `MQTT_PASS=changeme` is refused the same way an
unedited `.env.example` is.
- **`POST /api/apps/install/plan`.** New read-only endpoint that
returns the topo-sorted install order for a target app plus per-app
summaries (display_name, version, has_settings, installed flag). The
catalog UI calls this before opening the settings dialog so it can
show a confirm modal — "Installing zigbee2mqtt also installs
Mosquitto" — before anything mutates. Circular dependencies surface
as `400 {error: "circular dependency: A -> B -> A"}`; missing
providers as `400 {error: "required app 'X' not found …"}`.
- **`/var/lib/furtka/install-plan.json`** (overridable via
`FURTKA_INSTALL_PLAN`). The HTTP install endpoint writes this before
it spawns the systemd-run background job so the runner knows the
full chain to pull → create volumes → fire hooks → `compose up` for
in plan order. The runner consumes the file after reading so a stale
plan from a previous install can't accidentally steer the next one.
### Changed
- **`furtka reconcile` now visits apps in dependency order, not
alphabetical.** Topo-sort over `requires` puts providers before
consumers so a consumer's `on_start` hook can talk to an already-up
provider. Within a tier, ties stay alphabetical so boot logs are
still deterministic across reboots. Apps with unresolvable `requires`
(missing provider) are visited last; the per-app error-isolation in
reconcile then catches them without aborting the whole sweep.
- **`POST /api/apps/install` requires `confirm_dependencies: true`**
when installing a named app would pull in transitive providers.
Without the flag, the endpoint returns `409` plus the full plan body
so the UI can render the confirm dialog without a second round-trip.
Lone-target installs (no transitive deps) keep the existing
one-click flow — no UX change for `fileshare`-style standalone apps.
- **`furtka app install <name>` and the web UI now install transitive
dependencies automatically.** `furtka app install /path/to/dir`
stays as today (single-app, dev/test workflow).
- **`compose_exec` and `compose_exec_script` helpers** in
`furtka/dockerops.py`. Both pass `-T` (no TTY) so they work from the
install runner and from reconcile; both raise `DockerError` on
non-zero exit or timeout. `compose_exec_script` streams the script
body via stdin to `sh -s` so hooks don't need to be baked into the
provider's container image.
### Notes
- Hook target service: v1 auto-picks the *first* service in the
provider's compose config. Works for Mosquitto, Postgres, Redis.
Multi-service providers (Authentik server+worker) will need an
optional `service` field on the requirement entry; deferred until a
real case lands.
- Hook timeouts: `on_install` 60 s, `on_start` 30 s. Hardcoded for
v1 — revisit if a DB seed legitimately needs longer.
- Removing an app is now blocked (`409 {dependents: […]}` from the
API, exit 2 from the CLI) when other installed apps require it.
## [26.16-alpha] - 2026-05-10 ## [26.16-alpha] - 2026-05-10
### Added ### Added
@ -404,7 +573,11 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose - **Containers:** Docker + Compose
- **License:** AGPL-3.0 - **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.16-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha
[26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha
[26.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha
[26.16-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.16-alpha [26.16-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.16-alpha
[26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha [26.15-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.15-alpha
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha [26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha

View file

@ -102,6 +102,7 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] Installer webapp prototype — device reader + form → JSON (Robert) - [x] Installer webapp prototype — device reader + form → JSON (Robert)
- [x] Arch running on Proxmox, custom image builds in progress (Robert) - [x] Arch running on Proxmox, custom image builds in progress (Robert)
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md) - [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
- [x] First bare-metal install (old laptop board, BIOS, SSD with a previous Proxmox VE) — 4 bugs found and shipped in 26.20-alpha, report in [docs/hardware-test-2026-08.md](docs/hardware-test-2026-08.md)
- [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md) - [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md)
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged - [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/) - [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)

View file

@ -1,22 +1,36 @@
#!/bin/bash #!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the # Regenerates /etc/issue on the installed system so the console tells the
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern. # user which URL to open. Mirrors the live-ISO furtka-update-issue pattern:
set -e # runs at boot and every few seconds from furtka-welcome.timer, so the IP
# fallback line is always on screen — with the address once DHCP handed one
# out, or with a "no IP yet" hint before that / after the cable is pulled.
# Only rewrites + nudges agetty when the text changed (no prompt flicker).
set -u
hostname=$(cat /etc/hostname) issue=/etc/issue
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
{ hostname=$(cat /etc/hostname 2>/dev/null || hostname)
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
echo echo
echo " Furtka is ready." echo " Furtka is ready."
echo echo
echo " Open in a browser on another device on your network:" echo " Open in a browser on another device on your network:"
echo echo
echo " http://${hostname}.local (easy — try this first)" echo " http://${hostname}.local (easy — try this first)"
if [ -n "$ip" ]; then if ((${#ips[@]})); then
echo " http://${ip} (fallback if the first doesn't work)" for ip in "${ips[@]}"; do
echo " http://${ip} (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
} > /etc/issue )
agetty --reload 2>/dev/null || true if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -2,11 +2,20 @@
Description=Furtka apps catalog sync Description=Furtka apps catalog sync
Requires=network-online.target Requires=network-online.target
After=network-online.target After=network-online.target
# network-online.target is satisfied by *any* routable address. On dual-stack
# LANs that is usually the IPv6 SLAAC address, which lands a second or so
# before DHCPv4 — and the first sync then dies with "Network is unreachable"
# (first hardware bench, 2026-08-25). Retry a few times instead of waiting
# for the daily timer.
StartLimitIntervalSec=15min
StartLimitBurst=8
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka catalog sync ExecStart=/usr/local/bin/furtka catalog sync
TimeoutStartSec=5min TimeoutStartSec=5min
Restart=on-failure
RestartSec=20s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Furtka console welcome banner Description=Furtka console welcome banner
After=network-online.target # No network-online dependency on purpose: paint the banner (with a
Wants=network-online.target # "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
# it every few seconds so the address appears as soon as DHCP delivers.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -0,0 +1,10 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -322,6 +322,16 @@ details.log-details[open] > summary { color: var(--fg); }
} }
.modal .error.show, .modal .error.show,
.login-wrap .error.show { display: block; } .login-wrap .error.show { display: block; }
.modal .dep-list {
margin: 0 0 1rem;
padding: 0.75rem 1rem 0.75rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 0.9rem;
line-height: 1.4;
}
.modal .dep-list li { margin: 0.15rem 0; }
/* Login + first-run setup page. Shares .wrap's max-width so the form /* Login + first-run setup page. Shares .wrap's max-width so the form
sits in the same column the rest of the app uses, just without the sits in the same column the rest of the app uses, just without the

View file

@ -0,0 +1,75 @@
# Hardware test report — "junk bench", 2026-08-24/26
First end-to-end run of Furtka on real hardware that was not a Proxmox VM
and not the Medion box: a bare old-laptop mainboard lying on the desk, a
1 TB Crucial BX SATA SSD that had previously lived in a Proxmox VE host, a
USB hub for keyboard + boot stick, LAN cable, HDMI monitor. Tester: Daniel.
Release under test: **26.19-alpha**, then self-updated to **26.20-alpha**.
Why it mattered: every bug below is invisible on the smoke VM. A VM has a
fresh empty disk, a fast virtual NIC, UEFI firmware and no USB stick to pull.
## Timeline
| When | What happened | Outcome |
|---|---|---|
| 08-24 | ISO flashed with Fedora Media Writer, bench booted, browser opened `proski.local:5000` | "Server Not Found" — **typo** (`proski` vs `proksi`). Bench powered off. |
| 08-25 | `proksi.local:5000` — wizard came up (BIOS/syslinux boot path, stock Arch splash) | OK |
| 08-25 13:07 | Install run 1 | **Failed at 12 %**: `Partition(s) 2, 3 on /dev/sda have been written, but we have been unable to inform the kernel of the change … in use` |
| 08-25 | Root shell over SSH (`passwd` + `ssh-copy-id` on the live ISO): SSD still carried a Proxmox VE `pve` volume group (swap, root, thin pool); udev had activated it, six device-mapper nodes sat on `sda3` | Cleared by hand: `dmsetup remove` ×6, `wipefs -a /dev/sda`, `blockdev --rereadpt` |
| 08-25 13:16 | Install run 2 | `Installation completed without any errors`; BIOS detected → `Grub`, MBR table, `sda1` 1 G vfat `/boot`, `sda2` ext4 `/` |
| 08-25 | "Reboot now" clicked | Box **booted the USB stick again** (BIOS order). Stick pulled, rebooted → `furtka.local` = 192.168.178.142, Caddy answering |
| 08-25 16:22 | First boot of the installed system | `furtka-catalog-sync.service` failed: `Network is unreachable`. `network-online.target` had fired on the IPv6 SLAAC address ~1 s before DHCPv4 landed. State file stuck on `checking`, apps page empty; next timer run would have been 18:26 |
| 08-25 ~17:00 | Catalog synced via the "Sync apps catalog" button | Catalog 26.13-alpha, 7 apps listed |
| 08-25 17:47 | 26.20-alpha released with the fixes below | Box reports `Update available: 26.19-alpha → 26.20-alpha` |
| 08-26 09:34 | Self-update to 26.20-alpha from `/settings` | `current → 26.20-alpha`, no failed units. **New finding:** `furtka-welcome.timer` linked + enabled by the updater but `inactive (dead)``enable` alone only arms it for the next boot |
## Findings and fixes
| # | Finding | Root cause | Fix | Shipped in | Validated on hardware |
|---|---|---|---|---|---|
| 1 | Console banner showed only `proksi.local` / `furtka.local`, never the IP fallback | Banner script was a oneshot after `network-online.target` and dropped the IP line when no address existed yet. On the live ISO releng's root autologin on tty1 also made `agetty --reload` a no-op | `furtka-issue.timer` (ISO) / `furtka-welcome.timer` (installed) re-run the script every 5 s; fallback line is always present ("no IP address yet — check the cable / DHCP" until one arrives); ISO no longer auto-logs root in on tty1 | 26.20-alpha (`0e05bef`) | Installed side: pending reboot or next update. ISO side: pending reinstall from the 26.20 ISO |
| 2 | Install failed at partitioning on a disk with a previous OS | Live ISO ships lvm2/mdadm/dmraid/cryptsetup; udev auto-activates whatever the disk carries; parted cannot re-read the partition table while dm nodes hold the partitions | `webinstaller/diskprep.py`: `release_disk()` runs before archinstall — swapoff/umount leaves-first, `dmsetup remove` / `mdadm --stop`, `wipefs -a`, `blockdev --rereadpt`; steps logged at the top of the install log; progress page names this failure explicitly | 26.20-alpha (`5ddda53`) | Pending reinstall from the 26.20 ISO (the disk now carries Furtka itself, which exercises the same path minus LVM) |
| 3 | "Remove the USB stick" was easy to miss — the box booted the installer again | Hint lived on the *rebooting* page and in a `confirm()` popup, i.e. after the click, with the machine restarting 3 s later | Done state shows a two-step list (pull the stick → restart) above the button; button reads "USB stick is out — restart now" | 26.20-alpha (`60404ae`) | Pending reinstall |
| 4 | No apps after first boot | Boot-time catalog sync raced DHCPv4 (IPv6 made `network-online` fire early), no retry, timer jitter pushed the next attempt 2 h out, state file stuck on `checking` | `furtka-catalog-sync.service` retries on failure (20 s, up to 8× in 15 min); failures recorded as stage `error`; apps page shows "last sync failed: …" | 26.20-alpha (`32ba522`) | Retry config confirmed live on the box after the update; a cold boot with the new unit is still pending |
| 5 | New timer dead after self-update | `_link_new_units()` linked + enabled but never started the timer; also linked from the versioned dir instead of `current` | Link via `current`, enable, start | Unreleased (this branch) | — |
Not a bug, but worth knowing: on this board the BIOS boots the USB stick
before the SSD, so every reinstall needs the stick pulled at the reboot
prompt. The stock Arch splash on the BIOS/syslinux boot path is unbranded
(only menu labels are) — cosmetic.
## What worked first time
- Fedora Media Writer → hybrid ISO boots on BIOS firmware.
- mDNS: `proksi.local` (live ISO) and `furtka.local` (installed) resolved
from a Fedora client once spelled correctly.
- BIOS detection → `Grub` + MBR layout; the installed system booted from
the SSD without intervention.
- Login, `/settings`, catalog sync via the UI button, `furtka update --check`,
self-update 26.19 → 26.20 with symlink flip and clean unit state.
- Time sync, Docker 29.7.2, Caddy on :80, sshd with key auth for the wizard
user (`wheel` + `docker` groups as configured).
## Still open from this run
- Reinstall from the 26.20-alpha ISO on the bench to validate findings 13
on the ISO side (the installed side of #1 validates on the next reboot).
- Cold boot with the retrying catalog-sync unit (#4) on a dual-stack LAN.
- Rebrand the syslinux boot splash.
- `smoke-latest.yml` is `workflow_dispatch` only and has not run since
April; the smoke VM would not have caught any of the above anyway, but it
is currently not running at all.
## Reproducing the bench conditions in a VM
Most of this can be provoked on Proxmox if you want a regression check
without the hardware:
- Finding 2: attach a disk that already carries an LVM install (e.g. a
spare Proxmox VE or Ubuntu LVM disk image) before booting the ISO.
- Finding 3: leave the ISO attached as CD-ROM with boot order CD-first.
- Finding 4: give the VM's bridge IPv6 RA before DHCPv4 — harder; the
retry logic is unit-tested instead.
- Finding 1: use SeaBIOS instead of OVMF (the README notes SeaBIOS fails on
`ldlinux.c32` from this ISO, so this one really needs hardware).

View file

@ -21,7 +21,7 @@ import time
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from furtka import auth, dockerops, install_runner, installer, reconciler, sources from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import ManifestError, load_manifest from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir, static_www_dir from furtka.paths import apps_dir, static_www_dir
from furtka.scanner import scan from furtka.scanner import scan
@ -152,7 +152,10 @@ const modal = {
error: document.getElementById('modal-error'), error: document.getElementById('modal-error'),
submit: document.getElementById('modal-submit'), submit: document.getElementById('modal-submit'),
cancel: document.getElementById('modal-cancel'), cancel: document.getElementById('modal-cancel'),
current: null, // { name, action: 'install' | 'edit' } // current: { name, action: 'install' | 'edit', confirmDeps?: bool }
// confirmDeps == true means the dependency-confirm step was already passed,
// so submitModal sends confirm_dependencies:true to the API.
current: null,
}; };
modal.cancel.addEventListener('click', () => closeModal()); modal.cancel.addEventListener('click', () => closeModal());
@ -167,7 +170,7 @@ function closeModal() {
modal.current = null; modal.current = null;
} }
async function openSettingsDialog(name, action) { async function openSettingsDialog(name, action, opts = {}) {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`); const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
if (!r.ok) { if (!r.ok) {
document.getElementById('log').textContent = document.getElementById('log').textContent =
@ -175,7 +178,7 @@ async function openSettingsDialog(name, action) {
return; return;
} }
const data = await r.json(); const data = await r.json();
modal.current = { name, action }; modal.current = { name, action, confirmDeps: !!opts.confirmDeps };
modal.title.textContent = data.display_name || data.name; modal.title.textContent = data.display_name || data.name;
modal.long.textContent = data.description_long || data.description || ''; modal.long.textContent = data.description_long || data.description || '';
modal.long.style.display = modal.long.textContent ? '' : 'none'; modal.long.style.display = modal.long.textContent ? '' : 'none';
@ -221,10 +224,30 @@ modal.submit.addEventListener('click', submitModal);
const INSTALL_STAGE_LABELS = { const INSTALL_STAGE_LABELS = {
'pulling_image': 'Image wird heruntergeladen…', 'pulling_image': 'Image wird heruntergeladen…',
'creating_volumes': 'Speicherbereiche werden erstellt…', 'creating_volumes': 'Speicherbereiche werden erstellt…',
'running_hooks': 'Verknüpfungen werden eingerichtet…',
'starting_container': 'Container wird gestartet…', 'starting_container': 'Container wird gestartet…',
'done': 'Fertig', 'done': 'Fertig',
}; };
// Confirm dialog for transitive dependencies: "Installing X also installs
// Y, Z proceed?". Reuses the existing modal CSS/structure; the submit
// button on confirm reopens openSettingsDialog with confirmDeps=true.
async function openDependencyConfirmDialog(name, plan) {
modal.current = { name, action: 'install-confirm-deps' };
modal.title.textContent = `Install ${name}: dependencies required`;
const transitive = plan.summaries.filter(s => s.name !== name && !s.installed);
const long = transitive.length === 1
? `Installing ${name} requires ${transitive[0].display_name || transitive[0].name}. It will be installed and configured automatically.`
: `Installing ${name} requires ${transitive.length} additional apps. They will be installed and configured automatically.`;
modal.long.textContent = long;
modal.long.style.display = '';
modal.form.innerHTML = '<ul class="dep-list">'
+ transitive.map(s => `<li><strong>${esc(s.display_name || s.name)}</strong>${s.description ? '' + esc(s.description) : ''}</li>`).join('')
+ '</ul>';
modal.submit.textContent = 'Install all and continue';
modal.backdrop.classList.add('open');
}
async function pollInstallStatus(original) { async function pollInstallStatus(original) {
// Two-minute ceiling: Jellyfin over a slow DSL line can take ~90s // Two-minute ceiling: Jellyfin over a slow DSL line can take ~90s
// just on the image pull. Beyond that something's stuck — the // just on the image pull. Beyond that something's stuck — the
@ -261,7 +284,15 @@ async function pollInstallStatus(original) {
async function submitModal() { async function submitModal() {
if (!modal.current) return; if (!modal.current) return;
const { name, action } = modal.current; const { name, action, confirmDeps } = modal.current;
// Two-step dance: when this is the dep-confirm step, transition straight
// into the settings dialog with confirmDeps flagged so the actual install
// POST carries `confirm_dependencies: true`.
if (action === 'install-confirm-deps') {
closeModal();
await openSettingsDialog(name, 'install', { confirmDeps: true });
return;
}
const values = {}; const values = {};
for (const input of modal.form.querySelectorAll('input')) { for (const input of modal.form.querySelectorAll('input')) {
// In edit mode, skip password fields left blank server keeps existing. // In edit mode, skip password fields left blank server keeps existing.
@ -276,7 +307,9 @@ async function submitModal() {
const url = action === 'install' const url = action === 'install'
? '/api/apps/install' ? '/api/apps/install'
: `/api/apps/${encodeURIComponent(name)}/settings`; : `/api/apps/${encodeURIComponent(name)}/settings`;
const body = action === 'install' ? { name, settings: values } : { settings: values }; const body = action === 'install'
? { name, settings: values, ...(confirmDeps ? { confirm_dependencies: true } : {}) }
: { settings: values };
const r = await fetch(url, { const r = await fetch(url, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -363,8 +396,37 @@ async function refresh() {
} }
async function handleButton(op, name, btn) { async function handleButton(op, name, btn) {
if (op === 'install' || op === 'edit') { if (op === 'install') {
openSettingsDialog(name, op === 'install' ? 'install' : 'edit'); // Check dependencies before opening the settings form. If the target
// has transitive providers we need to install, show the confirm modal
// first; the user then proceeds to the regular settings form, which
// POSTs with confirm_dependencies:true.
try {
const r = await fetch('/api/apps/install/plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const plan = await r.json();
if (!r.ok) {
document.getElementById('log').textContent =
`[install ${name}] HTTP ${r.status}\\n` + JSON.stringify(plan, null, 2);
return;
}
const transitive = (plan.to_install || []).filter(n => n !== name);
if (transitive.length > 0) {
openDependencyConfirmDialog(name, plan);
return;
}
} catch (e) {
// Network blip fall through to the legacy single-app path; the API
// would reject again if deps actually were required.
}
openSettingsDialog(name, 'install');
return;
}
if (op === 'edit') {
openSettingsDialog(name, 'edit');
return; return;
} }
// Reinstall + update + remove are direct actions, no form. // Reinstall + update + remove are direct actions, no form.
@ -390,6 +452,9 @@ async function handleButton(op, name, btn) {
? ` updated ${data.services.length} service(s)` ? ` updated ${data.services.length} service(s)`
: ' — already up to date'; : ' — already up to date';
} }
if (op === 'remove' && r.status === 409 && data.dependents) {
header += ` blocked by: ${data.dependents.join(', ')}`;
}
document.getElementById('log').textContent = header + '\\n' + JSON.stringify(data, null, 2); document.getElementById('log').textContent = header + '\\n' + JSON.stringify(data, null, 2);
// Reinstall dispatches an async install the same way the modal does // Reinstall dispatches an async install the same way the modal does
// follow the background job on the button label until terminal. // follow the background job on the button label until terminal.
@ -425,7 +490,11 @@ async function refreshCatalog() {
const updatedAt = (status.state || {}).updated_at || ''; const updatedAt = (status.state || {}).updated_at || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never'; document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage'); const stageEl = document.getElementById('catalog-stage');
if (stage && stage !== 'done') { if (stage === 'error') {
const err = (status.state || {}).error || 'unknown error';
stageEl.textContent = '· last sync failed: ' + err;
stageEl.classList.add('pending');
} else if (stage && stage !== 'done') {
stageEl.textContent = '· ' + stage + ''; stageEl.textContent = '· ' + stage + '';
stageEl.classList.add('pending'); stageEl.classList.add('pending');
} else { } else {
@ -596,6 +665,10 @@ def _manifest_summary(m, app_dir=None):
# Optional template URL with `{host}` placeholder; frontend # Optional template URL with `{host}` placeholder; frontend
# substitutes against location.hostname at render time. # substitutes against location.hostname at render time.
"open_url": m.open_url, "open_url": m.open_url,
"requires": [
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires
],
} }
@ -695,7 +768,41 @@ def _do_get_settings(name):
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"}) _INSTALL_TERMINAL_STAGES = frozenset({"done", "error"})
def _do_install(name, settings=None): def _do_install_plan(name):
"""Compute a dependency plan for installing `name`.
Read-only no FS mutation, doesn't take the install lock. The UI calls
this before opening the settings dialog so it can show a "this will also
install X, Y" confirm step when the target has transitive deps.
"""
try:
plan = deps.plan_install(name)
except deps.DependencyError as e:
return 400, {"error": str(e)}
summaries: list[dict] = []
for app_name in plan.install_order:
m, _values, installed = _load_manifest_for(app_name)
if m is None:
return 400, {"error": f"could not load manifest for {app_name!r}"}
# Per-entry source folder for icon resolution — installed wins.
if installed:
src_dir = apps_dir() / app_name
else:
resolved = sources.resolve_app_name(app_name)
src_dir = resolved.path if resolved is not None else None
summary = _manifest_summary(m, src_dir)
summary["installed"] = installed
summaries.append(summary)
return 200, {
"target": plan.target,
"install_order": list(plan.install_order),
"already_installed": sorted(plan.already_installed),
"to_install": list(plan.to_install),
"summaries": summaries,
}
def _do_install(name, settings=None, confirm_dependencies=False):
"""Kick off an app install. Synchronous sync-phase + async docker-phase. """Kick off an app install. Synchronous sync-phase + async docker-phase.
Fast parts run inline so validation failures come back as immediate Fast parts run inline so validation failures come back as immediate
@ -722,6 +829,25 @@ def _do_install(name, settings=None):
) )
} }
# Resolve dependencies before taking the lock so a 4xx (cycle, missing
# provider) comes back fast without ever touching install state.
try:
plan = deps.plan_install(name)
except deps.DependencyError as e:
return 400, {"error": str(e)}
# If installing `name` pulls in transitive providers, require an explicit
# `confirm_dependencies: true` so the UI gets a chance to show the user
# what's about to land. The 409 body carries the plan so the UI can
# render the confirm modal without a second `/plan` round-trip.
transitive = [n for n in plan.to_install if n != name]
if transitive and not confirm_dependencies:
status, body = _do_install_plan(name)
if status != 200:
return status, body
body["error"] = "additional apps required — set confirm_dependencies: true"
return 409, body
# Fast-fail if another install is already in flight. Lock lives under # Fast-fail if another install is already in flight. Lock lives under
# /run/ so a previous reboot clears it automatically. # /run/ so a previous reboot clears it automatically.
try: try:
@ -730,13 +856,32 @@ def _do_install(name, settings=None):
return 409, {"error": str(e)} return 409, {"error": str(e)}
try: try:
try: try:
src = installer.resolve_source(name) if plan.to_install:
target = installer.install_from(src, settings=settings) targets = installer.install_plan(plan, settings_target=settings)
target = targets[-1]
else:
# Reinstall of an already-installed target with no new
# transitive providers — re-run the single-app path so an
# edited `.env` lands correctly.
src = installer.resolve_source(name)
target = installer.install_from(src, settings=settings)
except installer.InstallError as e: except installer.InstallError as e:
return 400, {"error": str(e)} return 400, {"error": str(e)}
# Write the plan file the background runner reads. Even single-app
# installs go through it so install_runner has one code path.
install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
install_runner.plan_path().write_text(
json.dumps(
{
"target": name,
"to_install": list(plan.to_install) if plan.to_install else [name],
}
)
)
# Initial state so the UI has something to show between this # Initial state so the UI has something to show between this
# response and the background job's first write. # response and the background job's first write.
install_runner.write_state("pulling_image", app=name) first_app = plan.to_install[0] if plan.to_install else name
install_runner.write_state("pulling_image", app=first_app, target=name)
finally: finally:
# Release the lock so the background job can re-acquire it. # Release the lock so the background job can re-acquire it.
fh.close() fh.close()
@ -801,6 +946,12 @@ def _do_remove(name):
target = apps_dir() / name target = apps_dir() / name
if not target.exists(): if not target.exists():
return 404, {"error": f"{name!r} is not installed"} return 404, {"error": f"{name!r} is not installed"}
dependents = deps.dependents_of(name)
if dependents:
return 409, {
"error": (f"{name!r} is required by: {', '.join(dependents)}. Remove those first."),
"dependents": list(dependents),
}
compose_warning = None compose_warning = None
try: try:
dockerops.compose_down(target, name) dockerops.compose_down(target, name)
@ -1369,11 +1520,14 @@ class _Handler(BaseHTTPRequestHandler):
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})
if self.path == "/api/apps/install": if self.path == "/api/apps/install/plan":
status, body = _do_install_plan(name)
elif self.path == "/api/apps/install":
settings = _parse_settings_body(payload) settings = _parse_settings_body(payload)
if settings is False: if settings is False:
return self._json(400, {"error": "'settings' must be an object"}) return self._json(400, {"error": "'settings' must be an object"})
status, body = _do_install(name, settings=settings) confirm = bool(payload.get("confirm_dependencies", False))
status, body = _do_install(name, settings=settings, confirm_dependencies=confirm)
elif self.path == "/api/apps/remove": elif self.path == "/api/apps/remove":
status, body = _do_remove(name) status, body = _do_remove(name)
else: else:

View file

@ -204,50 +204,63 @@ def _atomic_swap(staging: Path) -> None:
def sync_catalog() -> CatalogCheck: def sync_catalog() -> CatalogCheck:
"""End-to-end sync. Acquires the lock, writes state at each stage, and """End-to-end sync. Acquires the lock, writes state at each stage, and
leaves the live catalog untouched on any failure before the rename step. leaves the live catalog untouched on any failure before the rename step.
A failure lands as stage ``"error"`` (with the message) instead of leaving
the last in-flight stage behind: the first hardware bench sat on
``"checking"`` for hours after a boot-time network failure, and the UI
showed "checking…" with nothing actually happening.
""" """
with acquire_lock(): with acquire_lock():
write_state("checking")
check = check_catalog()
if not check.update_available:
write_state("done", version=check.current or check.latest, note="already up to date")
return check
if not check.tarball_url or not check.sha256_url:
raise CatalogError("catalog release is missing tarball or sha256 asset")
# Downloads land in a sibling of the live catalog so half-finished
# artefacts never pollute the live tree, and stay under /var/lib/
# furtka/ so a sync interrupted by reboot can resume instead of
# starting over from /tmp (which clears).
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
dl_dir.mkdir(parents=True, exist_ok=True)
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
write_state("downloading", latest=check.latest)
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
write_state("verifying", latest=check.latest)
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
write_state("extracting", latest=check.latest)
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
if staging.exists():
shutil.rmtree(staging)
try: try:
_rc.extract_tarball(tarball, staging, error_cls=CatalogError) return _sync_locked()
_validate_staging(staging, check.latest) except CatalogError as e:
except CatalogError: write_state("error", error=str(e))
shutil.rmtree(staging, ignore_errors=True)
raise raise
write_state("swapping", latest=check.latest)
try:
_atomic_swap(staging)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
raise
write_state("done", version=check.latest, previous=check.current) def _sync_locked() -> CatalogCheck:
write_state("checking")
check = check_catalog()
if not check.update_available:
write_state("done", version=check.current or check.latest, note="already up to date")
return check return check
if not check.tarball_url or not check.sha256_url:
raise CatalogError("catalog release is missing tarball or sha256 asset")
# Downloads land in a sibling of the live catalog so half-finished
# artefacts never pollute the live tree, and stay under /var/lib/
# furtka/ so a sync interrupted by reboot can resume instead of
# starting over from /tmp (which clears).
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
dl_dir.mkdir(parents=True, exist_ok=True)
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
write_state("downloading", latest=check.latest)
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
write_state("verifying", latest=check.latest)
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
write_state("extracting", latest=check.latest)
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
if staging.exists():
shutil.rmtree(staging)
try:
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
_validate_staging(staging, check.latest)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
raise
write_state("swapping", latest=check.latest)
try:
_atomic_swap(staging)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
raise
write_state("done", version=check.latest, previous=check.current)
return check

View file

@ -1,8 +1,9 @@
import argparse import argparse
import json import json
import sys import sys
from pathlib import Path
from furtka import dockerops, installer, reconciler from furtka import deps, dockerops, installer, reconciler
from furtka.paths import apps_dir from furtka.paths import apps_dir
from furtka.scanner import scan from furtka.scanner import scan
@ -37,6 +38,14 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
} }
for s in r.manifest.settings for s in r.manifest.settings
], ],
"requires": [
{
"app": req.app,
"on_install": req.on_install,
"on_start": req.on_start,
}
for req in r.manifest.requires
],
} }
if r.manifest if r.manifest
else None, else None,
@ -58,17 +67,64 @@ def _cmd_app_list(args: argparse.Namespace) -> int:
def _cmd_app_install(args: argparse.Namespace) -> int: def _cmd_app_install(args: argparse.Namespace) -> int:
# If the user passed a path (or a path-ish thing), bypass dep resolution —
# local paths are dev/test workflows where the caller knows what they want.
# These just copy + reconcile; no `requires`/hook handling.
src_path = Path(args.source)
is_path = src_path.is_dir() or "/" in args.source or args.source.startswith(".")
if is_path:
try:
src = installer.resolve_source(args.source)
target = installer.install_from(src)
except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(f"installed {target.name} to {target}")
actions = reconciler.reconcile(apps_dir())
for a in actions:
print(f" {a.describe()}")
return 1 if reconciler.has_errors(actions) else 0
# Catalog/bundled name install: resolve transitive `requires`, copy every
# app folder (the synchronous phase the API runs inline), then drive the
# exact same docker phase the API dispatches via systemd-run — so providers
# come up before consumers and `on_install` hooks fire to provision them.
# Going through run_install (not a bare reconcile) is what makes a CLI
# install of a consumer actually provision against its provider.
from furtka import install_runner
try: try:
src = installer.resolve_source(args.source) plan = deps.plan_install(args.source)
target = installer.install_from(src) except deps.DependencyError as e:
print(f"error: {e}", file=sys.stderr)
return 2
try:
if plan.to_install:
installer.install_plan(plan)
to_install = list(plan.to_install)
else:
# Target already installed — reinstall to refresh files, then still
# run the docker phase so hooks re-fire (matches API reinstall).
installer.install_from(installer.resolve_source(args.source))
to_install = [args.source]
except installer.InstallError as e: except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 2 return 2
print(f"installed {target.name} to {target}")
actions = reconciler.reconcile(apps_dir()) # Stage the plan file run_install consumes (it removes it after reading).
for a in actions: install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
print(f" {a.describe()}") install_runner.plan_path().write_text(
return 1 if reconciler.has_errors(actions) else 0 json.dumps({"target": args.source, "to_install": to_install})
)
try:
install_runner.run_install(args.source)
except Exception as e:
# run_install already wrote state="error"; surface it to the caller.
print(f"error: {e}", file=sys.stderr)
return 1
for name in to_install:
print(f"installed {name}")
return 0
def _cmd_app_install_bg(args: argparse.Namespace) -> int: def _cmd_app_install_bg(args: argparse.Namespace) -> int:
@ -94,6 +150,13 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
if not target.exists(): if not target.exists():
print(f"error: {args.name!r} is not installed", file=sys.stderr) print(f"error: {args.name!r} is not installed", file=sys.stderr)
return 1 return 1
dependents = deps.dependents_of(args.name)
if dependents:
print(
f"error: {args.name!r} is required by: {', '.join(dependents)}. Remove those first.",
file=sys.stderr,
)
return 2
try: try:
dockerops.compose_down(target, args.name) dockerops.compose_down(target, args.name)
except dockerops.DockerError as e: except dockerops.DockerError as e:

237
furtka/deps.py Normal file
View file

@ -0,0 +1,237 @@
"""App-to-app dependency planning.
A manifest may declare ``requires: [{"app": "<name>", "on_install": ...,
"on_start": ...}]``. This module turns that graph into:
- ``plan_install(name)`` topo-sorted install order so providers come up
before consumers, with cycle detection. Read-only over the catalog +
installed tree; the installer is the one that mutates.
- ``dependents_of(name)`` installed apps that name ``<name>`` in their
``requires``. Used by the remove guard to block "rip out mosquitto"
while zigbee2mqtt is still installed.
- ``installed_topo_order(scan_results)`` re-order a list of installed
apps so reconcile's per-boot sweep visits providers before consumers
(so a consumer's ``on_start`` hook runs against an already-up provider).
- ``provider_exec_service(provider_dir, project)`` pick the compose
service to ``docker compose exec`` into when firing a hook. v1: first
service in the provider's compose config.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from furtka import dockerops, sources
from furtka.manifest import Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir
from furtka.scanner import ScanResult, scan
class DependencyError(RuntimeError):
pass
@dataclass(frozen=True)
class DepPlan:
target: str
install_order: tuple[str, ...] # topo: providers first, target last
already_installed: frozenset[str]
to_install: tuple[str, ...] # install_order minus already_installed
def _load_any(name: str) -> Manifest | None:
"""Load `<name>`'s manifest — prefer installed, fall back to catalog/bundled.
Returns None if the app exists nowhere we can see. Caller decides how
loud to be about that `plan_install` raises, `dependents_of` just
skips entries it can't parse so reconcile keeps working.
"""
installed = apps_dir() / name / "manifest.json"
if installed.is_file():
try:
return load_manifest(installed, expected_name=name)
except ManifestError:
return None
src = sources.resolve_app_name(name)
if src is None:
return None
try:
return load_manifest(src.path / "manifest.json")
except ManifestError:
return None
def _installed_names() -> frozenset[str]:
return frozenset(r.manifest.name for r in scan(apps_dir()) if r.ok)
def plan_install(name: str) -> DepPlan:
"""Build a topo-sorted install plan for `name`.
Walks the dependency graph via the catalog/bundled+installed manifests,
detects cycles, and returns the order plus which entries are already
installed (those get skipped at install time but stay in `install_order`
for sequencing the `on_install` hooks correctly).
"""
WHITE, GRAY, BLACK = 0, 1, 2
color: dict[str, int] = {}
order: list[str] = []
stack_chain: list[str] = []
# Iterative post-order DFS with a per-frame iterator over children.
# Cycle detection uses GRAY-on-GRAY (Tarjan-style) so a chain through
# several apps still surfaces the full path in the error message.
def visit(start: str) -> None:
if color.get(start, WHITE) == BLACK:
return
# Each frame: (name, manifest, iterator over sorted requires)
m = _load_any(start)
if m is None:
raise DependencyError(
f"required app {start!r} not found in installed apps, catalog, or bundled apps"
)
# Sort requires alphabetically for deterministic install order.
children = iter(sorted(r.app for r in m.requires))
stack: list[tuple[str, Manifest, "object"]] = [(start, m, children)] # noqa: UP037
color[start] = GRAY
stack_chain.append(start)
while stack:
cur_name, cur_m, it = stack[-1]
child = next(it, None)
if child is None:
# All children processed — emit and pop.
color[cur_name] = BLACK
order.append(cur_name)
stack.pop()
stack_chain.pop()
continue
c = color.get(child, WHITE)
if c == BLACK:
continue
if c == GRAY:
# Cycle — find the back-edge target in the chain and report.
idx = stack_chain.index(child)
cycle = " -> ".join(stack_chain[idx:] + [child])
raise DependencyError(f"circular dependency: {cycle}")
# WHITE — descend.
child_m = _load_any(child)
if child_m is None:
raise DependencyError(
f"required app {child!r} (needed by {cur_name!r}) "
"not found in installed apps, catalog, or bundled apps"
)
color[child] = GRAY
stack_chain.append(child)
stack.append((child, child_m, iter(sorted(r.app for r in child_m.requires))))
visit(name)
installed = _installed_names()
to_install = tuple(n for n in order if n not in installed)
return DepPlan(
target=name,
install_order=tuple(order),
already_installed=frozenset(n for n in order if n in installed),
to_install=to_install,
)
def dependents_of(name: str) -> tuple[str, ...]:
"""Names of installed apps that declare `<name>` in their `requires`.
Used by the remove guard. Result is sorted alphabetically so error
messages read in a stable order.
"""
out: list[str] = []
for r in scan(apps_dir()):
if not r.ok:
continue
if any(req.app == name for req in r.manifest.requires):
out.append(r.manifest.name)
out.sort()
return tuple(out)
def installed_topo_order(results: list[ScanResult]) -> list[ScanResult]:
"""Re-order installed apps so providers come before consumers.
Apps whose `requires` point at uninstalled providers (or that contain
cycles) are emitted at the tail in their original order reconcile
already isolates per-app failure so we don't want to abort the whole
sweep on a misconfigured manifest. Ties within a tier stay alphabetical
(the scanner already returns alphabetical), matching the deterministic
boot order users rely on.
"""
ok = [r for r in results if r.ok]
bad = [r for r in results if not r.ok]
by_name = {r.manifest.name: r for r in ok}
# Kahn's algorithm against the installed subgraph only. Edges from
# consumer -> provider; we want providers first, so build the indegree
# over consumers ("how many of MY providers are still pending").
pending_providers: dict[str, set[str]] = {}
consumers_of: dict[str, list[str]] = {n: [] for n in by_name}
for r in ok:
deps = {req.app for req in r.manifest.requires if req.app in by_name}
pending_providers[r.manifest.name] = deps
for dep in deps:
consumers_of[dep].append(r.manifest.name)
# Seed with anything that has no installed providers, alphabetical.
ready = sorted(n for n, deps in pending_providers.items() if not deps)
ordered: list[str] = []
while ready:
# Pop the alphabetically-smallest so ties stay deterministic.
n = ready.pop(0)
ordered.append(n)
for consumer in consumers_of[n]:
pending_providers[consumer].discard(n)
if not pending_providers[consumer]:
# Insert in sorted position.
_insort(ready, consumer)
# Anything left has unresolved providers (missing or cyclic) — append
# in scanner order so reconcile still tries them and gets a clean
# per-app error.
leftover = [n for n in by_name if n not in set(ordered)]
leftover_set = set(leftover)
leftover_in_scan_order = [r.manifest.name for r in ok if r.manifest.name in leftover_set]
out = [by_name[n] for n in ordered]
out.extend(by_name[n] for n in leftover_in_scan_order)
out.extend(bad) # broken manifests already had their place in `results`; append last
return out
def _insort(seq: list[str], value: str) -> None:
"""Insert `value` into the sorted list `seq` (keeping it sorted)."""
lo, hi = 0, len(seq)
while lo < hi:
mid = (lo + hi) // 2
if seq[mid] < value:
lo = mid + 1
else:
hi = mid
seq.insert(lo, value)
def provider_exec_service(provider_dir: Path, project: str) -> str:
"""Pick the compose service name to `docker compose exec` into for a hook.
v1: first service in the provider's compose file. Works for the apps we
actually have (Mosquitto, Postgres, Redis all single-service). When a
multi-service provider (Authentik etc.) lands, the deferred follow-up is
to add an explicit `service` field on the Requirement entry.
Falls back to the project name if compose config can't be read — that's
a desperate guess but better than crashing, and the resulting exec error
will be surfaced cleanly as a DockerError to the caller.
"""
try:
cfg = dockerops.compose_image_tags(provider_dir, project)
except dockerops.DockerError:
return project
if not cfg:
return project
return next(iter(cfg.keys()))

View file

@ -60,6 +60,88 @@ def compose_pull(app_dir: Path, project: str) -> None:
_run([*_compose_args(app_dir, project), "pull"], cwd=app_dir) _run([*_compose_args(app_dir, project), "pull"], cwd=app_dir)
def compose_exec(
app_dir: Path,
project: str,
service: str,
argv: list[str],
*,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""`docker compose exec -T <service> <argv...>`. Returns captured stdout.
`-T` disables TTY allocation required when called from a non-interactive
parent (the install background job, the reconcile service). Without it,
docker exits with "the input device is not a TTY".
"""
cmd = [*_compose_args(app_dir, project), "exec", "-T"]
for k, v in (env or {}).items():
cmd.extend(["--env", f"{k}={v}"])
cmd.append(service)
cmd.extend(argv)
try:
proc = subprocess.run(
cmd,
cwd=app_dir,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
raise DockerError(f"compose exec {service}: timed out after {timeout}s") from e
if proc.returncode != 0:
msg = proc.stderr.strip() or proc.stdout.strip()
raise DockerError(f"compose exec {service} exited {proc.returncode}: {msg}")
return proc.stdout
def compose_exec_script(
app_dir: Path,
project: str,
service: str,
script_path: Path,
*,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""Run a host-side script inside the compose container via `sh -s`.
The script's bytes are streamed on stdin, so it doesn't need to be
copied into the image. Used by the app-dependency feature to run a
provider's hook scripts (e.g. "create an MQTT user for the consumer")
when a consumer is being installed or every time it starts.
Returns the script's stdout as text (UTF-8, replace-on-error). Raises
DockerError on non-zero exit or timeout, mirroring `compose_exec`.
"""
body = Path(script_path).read_bytes()
cmd = [*_compose_args(app_dir, project), "exec", "-T"]
for k, v in (env or {}).items():
cmd.extend(["--env", f"{k}={v}"])
cmd.extend([service, "sh", "-s"])
try:
proc = subprocess.run(
cmd,
cwd=app_dir,
check=False,
input=body,
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as e:
raise DockerError(
f"compose exec {service}: hook {script_path.name} timed out after {timeout}s"
) from e
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
raise DockerError(
f"compose exec {service} hook {script_path.name} exited {proc.returncode}: {err}"
)
return proc.stdout.decode("utf-8", "replace")
def compose_image_tags(app_dir: Path, project: str) -> dict[str, str]: def compose_image_tags(app_dir: Path, project: str) -> dict[str, str]:
"""Return {service_name: image_tag} as declared in the compose file. """Return {service_name: image_tag} as declared in the compose file.

View file

@ -8,23 +8,31 @@ This module mirrors the exact same shape as ``furtka.catalog`` and
``furtka.updater`` so the UI can poll an install just like it polls a ``furtka.updater`` so the UI can poll an install just like it polls a
catalog sync or a self-update. The split is: catalog sync or a self-update. The split is:
- ``furtka.api._do_install`` runs synchronously: resolve source, copy - ``furtka.api._do_install`` runs synchronously: resolve source(s), copy
the app folder, write .env, validate path settings + placeholders. the app folder(s), write .env. Those are fast, and their failures
Those are fast, and their failures deserve an immediate 4xx so the deserve an immediate 4xx so the install modal can surface them in-line.
install modal can surface them in-line.
- After that the API writes an initial state file (stage - After that the API writes an initial state file (stage
"pulling_image") and dispatches ``systemd-run --unit=furtka-install- "pulling_image") and dispatches ``systemd-run --unit=furtka-install-
<name>`` to run ``furtka app install-bg <name>`` in the background. <name>`` to run ``furtka app install-bg <name>`` in the background.
That CLI subcommand is what calls ``run_install()`` here it does the That CLI subcommand is what calls ``run_install()`` here it does the
docker-facing phases and writes state transitions as it goes. docker-facing phases and writes state transitions as it goes.
If the API also wrote a plan file at ``/var/lib/furtka/install-plan.json``
(because the target had transitive dependencies), the runner iterates
through every app in ``to_install`` pulling, creating volumes, firing
``on_install`` hooks against already-up providers, then ``compose up``
so providers are ready before consumers' hooks try to talk to them. The
state file's ``target`` field carries the original user-chosen app name
so the UI can show "Installing mosquitto (required by zigbee2mqtt)".
State file schema (``/var/lib/furtka/install-state.json``): State file schema (``/var/lib/furtka/install-state.json``):
{ {
"stage": "pulling_image" | "creating_volumes" "stage": "pulling_image" | "creating_volumes"
| "starting_container" | "done" | "error", | "running_hooks" | "starting_container" | "done" | "error",
"updated_at": "2026-04-21T17:30:45+0200", "updated_at": "2026-04-21T17:30:45+0200",
"app": "jellyfin", "app": "mosquitto", // app currently being processed
"target": "zigbee2mqtt", // original target (== app for single-app installs)
"version": "1.0.0", // added at "done" "version": "1.0.0", // added at "done"
"error": "details..." // added at "error" "error": "details..." // added at "error"
} }
@ -40,16 +48,21 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os import os
import re
import time import time
from pathlib import Path from pathlib import Path
from furtka import dockerops from furtka import deps, dockerops, installer
from furtka.manifest import load_manifest from furtka.manifest import SETTING_NAME_RE, Manifest, load_manifest
from furtka.paths import apps_dir from furtka.paths import apps_dir
_INSTALL_STATE = Path(os.environ.get("FURTKA_INSTALL_STATE", "/var/lib/furtka/install-state.json")) _INSTALL_STATE = Path(os.environ.get("FURTKA_INSTALL_STATE", "/var/lib/furtka/install-state.json"))
_INSTALL_PLAN = Path(os.environ.get("FURTKA_INSTALL_PLAN", "/var/lib/furtka/install-plan.json"))
_LOCK_PATH = Path(os.environ.get("FURTKA_INSTALL_LOCK", "/run/furtka/install.lock")) _LOCK_PATH = Path(os.environ.get("FURTKA_INSTALL_LOCK", "/run/furtka/install.lock"))
_ON_INSTALL_TIMEOUT_SECONDS = 60.0
_FURTKA_JSON_RE = re.compile(r"^FURTKA_JSON:\s*(.*)$")
class InstallRunnerError(RuntimeError): class InstallRunnerError(RuntimeError):
"""Any failure in the background install flow that should surface to the caller.""" """Any failure in the background install flow that should surface to the caller."""
@ -59,6 +72,10 @@ def state_path() -> Path:
return _INSTALL_STATE return _INSTALL_STATE
def plan_path() -> Path:
return _INSTALL_PLAN
def lock_path() -> Path: def lock_path() -> Path:
return _LOCK_PATH return _LOCK_PATH
@ -79,6 +96,35 @@ def read_state() -> dict:
return {} return {}
def _read_plan(target: str) -> dict:
"""Load the install plan if the API wrote one; otherwise the single-app fallback.
The plan file is consumed once removed after read so a stale plan from
a previous install can't accidentally steer this run. If the file is
missing/unparseable we synthesize a one-element plan from the target arg
so the old single-app behaviour still works (CLI invocations, smoke tests).
"""
try:
raw = plan_path().read_text()
except (FileNotFoundError, OSError):
return {"target": target, "to_install": [target]}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {"target": target, "to_install": [target]}
finally:
try:
plan_path().unlink()
except OSError:
pass
if not isinstance(data, dict):
return {"target": target, "to_install": [target]}
return {
"target": data.get("target", target),
"to_install": data.get("to_install") or [target],
}
def acquire_lock(): def acquire_lock():
path = lock_path() path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@ -91,31 +137,163 @@ def acquire_lock():
return fh return fh
def _parse_hook_output(text: str) -> dict[str, str]:
"""Extract KEY=VALUE pairs from hook stdout plus any FURTKA_JSON: {...} line.
KEY=VALUE keys must match the manifest's SETTING_NAME regex (UPPER_SNAKE_CASE)
so a misbehaving hook can't inject e.g. `PATH=` and clobber the container's
runtime environment.
The FURTKA_JSON sentinel is opt-in for hooks that need to return structured
data later (e.g. a list of generated certificates). Only string values are
accepted; non-string values raise so a hook can't smuggle non-env content
into the .env file. JSON values overlay KEY=VALUE values.
"""
out: dict[str, str] = {}
# First pass: skip FURTKA_JSON lines for KEY=VALUE extraction.
kv_lines = [line for line in text.splitlines() if not _FURTKA_JSON_RE.match(line.strip())]
kv = installer.parse_env_text("\n".join(kv_lines))
for key, value in kv.items():
if not SETTING_NAME_RE.match(key):
raise InstallRunnerError(
f"hook returned invalid env-var name {key!r} "
"(must be UPPER_SNAKE_CASE, e.g. MQTT_USER)"
)
out[key] = value
# Second pass: pick up FURTKA_JSON sentinels.
for raw in text.splitlines():
m = _FURTKA_JSON_RE.match(raw.strip())
if not m:
continue
try:
payload = json.loads(m.group(1))
except json.JSONDecodeError as e:
raise InstallRunnerError(f"hook returned invalid FURTKA_JSON payload: {e}") from e
if not isinstance(payload, dict):
raise InstallRunnerError(
"hook FURTKA_JSON payload must be an object of KEY=VALUE strings"
)
for key, value in payload.items():
if not isinstance(key, str) or not SETTING_NAME_RE.match(key):
raise InstallRunnerError(f"hook FURTKA_JSON key {key!r} must be UPPER_SNAKE_CASE")
if not isinstance(value, str):
raise InstallRunnerError(f"hook FURTKA_JSON value for {key!r} must be a string")
out[key] = value
return out
def _merge_hook_output_into_env(env_path: Path, hook_stdout: str) -> None:
"""Overlay hook-returned keys onto an app's `.env`. Hook wins on conflict.
Re-runs the placeholder-secret check so a hook returning literal "changeme"
is refused the same way an unedited .env.example is. Re-chmods to 0600 so
even an interrupted run leaves the file root-only.
"""
overlay = _parse_hook_output(hook_stdout)
if not overlay:
return
existing = installer.read_env_values(env_path)
merged: dict[str, str] = {}
merged.update(existing)
merged.update(overlay) # hook wins
installer.write_env(env_path, merged)
env_path.chmod(0o600)
bad = installer._placeholder_keys(env_path)
if bad:
raise InstallRunnerError(
f"{env_path}: hook returned placeholder values for {', '.join(bad)}"
)
def _fire_install_hooks(consumer: Manifest, consumer_dir: Path) -> None:
"""Run each `on_install` hook against the corresponding provider's container.
The provider must already be running (its `compose up` ran earlier in the
same plan). Hook stdout is parsed via `_parse_hook_output` and merged into
the consumer's `.env` before its own `compose up` fires.
"""
for req in consumer.requires:
if not req.on_install:
continue
provider_dir = apps_dir() / req.app
provider_manifest_path = provider_dir / "manifest.json"
if not provider_manifest_path.is_file():
raise InstallRunnerError(f"{consumer.name}: required app {req.app!r} is not installed")
# Validate provider manifest loads (matches the contract the rest of
# the system relies on — never trust a provider folder with a busted
# manifest).
load_manifest(provider_manifest_path, expected_name=req.app)
hook_abs = provider_dir / req.on_install
if not hook_abs.is_file():
raise InstallRunnerError(
f"{consumer.name}: on_install hook {req.on_install!r} missing in provider {req.app}"
)
service = deps.provider_exec_service(provider_dir, req.app)
stdout = dockerops.compose_exec_script(
provider_dir,
req.app,
service,
hook_abs,
env={
"FURTKA_CONSUMER_APP": consumer.name,
"FURTKA_CONSUMER_VERSION": consumer.version,
},
timeout=_ON_INSTALL_TIMEOUT_SECONDS,
)
_merge_hook_output_into_env(consumer_dir / ".env", stdout)
def run_install(name: str) -> None: def run_install(name: str) -> None:
"""Docker-facing phases of the install: pull → volumes → compose up. """Docker-facing phases of the install: pull → volumes → hooks → compose up.
Called by the ``furtka app install-bg <name>`` CLI subcommand from the Called by the ``furtka app install-bg <name>`` CLI subcommand from the
systemd-run spawned by the API. Assumes the API has already run systemd-run spawned by the API. Assumes the API has already run
``installer.install_from()``, so the app folder, .env, and manifest ``installer.install_from()`` for every app in the plan, so each app folder,
are on disk at ``apps_dir() / <name>``. `.env`, and manifest are on disk under ``apps_dir() / <name>``.
Every phase transition is written to the state file for the UI to If ``/var/lib/furtka/install-plan.json`` exists, every app in its
poll. On exception the state flips to ``"error"`` with the message, ``to_install`` is processed in order (providers before consumers). Each
then the exception is re-raised so the CLI exits non-zero and provider is fully up before the consumer's ``on_install`` hooks fire,
journald has a traceback. so a hook can ``mosquitto_passwd``/`createuser` against a live broker/DB.
Every phase transition is written to the state file for the UI to poll.
On exception the state flips to ``"error"`` with the message, then the
exception is re-raised so the CLI exits non-zero and journald gets a
traceback. Per-app failure aborts the rest of the plan: a half-installed
consumer whose provider is fine is recoverable by retrying.
""" """
with acquire_lock(): with acquire_lock():
target = apps_dir() / name plan = _read_plan(name)
manifest = load_manifest(target / "manifest.json", expected_name=name) target = plan["target"]
to_install = list(plan["to_install"])
try: try:
write_state("pulling_image", app=name) last_manifest = None
dockerops.compose_pull(target, name) for app_name in to_install:
write_state("creating_volumes", app=name) target_dir = apps_dir() / app_name
for short in manifest.volumes: m = load_manifest(target_dir / "manifest.json", expected_name=app_name)
dockerops.ensure_volume(manifest.volume_name(short)) last_manifest = m
write_state("starting_container", app=name) write_state("pulling_image", app=app_name, target=target)
dockerops.compose_up(target, name) dockerops.compose_pull(target_dir, app_name)
write_state("done", app=name, version=manifest.version) write_state("creating_volumes", app=app_name, target=target)
for short in m.volumes:
dockerops.ensure_volume(m.volume_name(short))
if m.requires:
write_state("running_hooks", app=app_name, target=target)
_fire_install_hooks(m, target_dir)
write_state("starting_container", app=app_name, target=target)
dockerops.compose_up(target_dir, app_name)
# Terminal state carries the original target's name + version so
# the UI's poll loop ("is install of <target> done yet?") still
# works unchanged.
if last_manifest is not None and last_manifest.name == target:
write_state("done", app=target, target=target, version=last_manifest.version)
else:
# Fallback: target wasn't last in the plan (shouldn't happen for
# a well-formed plan, but don't crash on the terminal write).
write_state("done", app=target, target=target)
except Exception as e: except Exception as e:
write_state("error", app=name, error=str(e)) current = read_state().get("app", target)
write_state("error", app=current, target=target, error=str(e))
raise raise

View file

@ -171,13 +171,22 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): 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(): if not item.is_file():
continue continue
# Never overwrite an existing user .env — either settings-driven write # Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority. # or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists(): if item.name == ".env" and (target / ".env").exists():
continue continue
shutil.copy2(item, target / item.name) shutil.copy2(item, dest)
env = target / ".env" env = target / ".env"
env_example = target / ".env.example" env_example = target / ".env.example"
@ -233,10 +242,15 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
return target return target
def _read_env(env_path: Path) -> dict[str, str]: def parse_env_text(text: str) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values.""" """Parse KEY=VALUE lines from a string into a dict. Unquotes quoted values.
Reusable by anything that needs the same lenient .env parsing logic
without reading a file e.g. hook script stdout merged into an app's
.env during install (see install_runner._fire_install_hooks).
"""
out: dict[str, str] = {} out: dict[str, str] = {}
for raw in env_path.read_text().splitlines(): for raw in text.splitlines():
line = raw.strip() line = raw.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
@ -250,6 +264,11 @@ def _read_env(env_path: Path) -> dict[str, str]:
return out return out
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
return parse_env_text(env_path.read_text())
def read_env_values(env_path: Path) -> dict[str, str]: def read_env_values(env_path: Path) -> dict[str, str]:
"""Public wrapper — returns {} if the file doesn't exist.""" """Public wrapper — returns {} if the file doesn't exist."""
if not env_path.exists(): if not env_path.exists():
@ -307,6 +326,28 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
return target return target
def install_plan(plan, settings_target: dict[str, str] | None = None) -> list[Path]:
"""Run the synchronous install phase for every app in `plan.to_install`.
Each name is resolved via `resolve_source()` and copied via `install_from`
in plan order, so providers land before consumers. Only the target app
receives user-supplied settings transitive providers install from their
catalog/bundled `.env.example` and rely on the placeholder-secret check
to refuse if anyone shipped a "changeme" default.
No rollback on partial failure. Re-running install is the recovery path;
stopping providers a user may already rely on for other apps is more
destructive than a partial state. Returns the list of target folders in
install order.
"""
targets: list[Path] = []
for name in plan.to_install:
src = resolve_source(name)
settings = settings_target if name == plan.target else None
targets.append(install_from(src, settings=settings))
return targets
def remove(name: str) -> Path: def remove(name: str) -> Path:
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched. """Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.

View file

@ -15,6 +15,7 @@ REQUIRED_FIELDS = (
VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"}) VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
APP_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
class ManifestError(Exception): class ManifestError(Exception):
@ -31,6 +32,18 @@ class Setting:
default: str | None default: str | None
@dataclass(frozen=True)
class Requirement:
app: str # name of the required app — must resolve in installed/catalog/bundled
# Hook paths are relative to the PROVIDER's app folder (not the consumer's).
# Resolved at hook-fire time, not manifest-load time — the provider may not
# be installed yet when this manifest is parsed.
# on_install: script run via `docker compose exec` on the provider during install.
on_install: str | None
# on_start: script run on every boot before the consumer starts (must be idempotent).
on_start: str | None
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
name: str name: str
@ -48,6 +61,7 @@ class Manifest:
# furtka.local, a raw IP, a future reverse-proxy hostname. Apps with # furtka.local, a raw IP, a future reverse-proxy hostname. Apps with
# no frontend (CLI-only, background workers) leave this empty. # no frontend (CLI-only, background workers) leave this empty.
open_url: str = "" open_url: str = ""
requires: tuple[Requirement, ...] = field(default_factory=tuple)
def volume_name(self, short: str) -> str: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # Namespace volume names so two apps can each declare e.g. "data"
@ -98,6 +112,49 @@ def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
return tuple(out) return tuple(out)
def _validate_hook_path(value: object, manifest_path: Path, where: str) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value:
raise ManifestError(f"{manifest_path}: {where} must be a non-empty string if set")
if value.startswith("/"):
raise ManifestError(f"{manifest_path}: {where} must be relative (no leading /)")
parts = value.replace("\\", "/").split("/")
if any(p == ".." for p in parts):
raise ManifestError(f"{manifest_path}: {where} must not contain '..'")
return value
def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[Requirement, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ManifestError(f"{manifest_path}: requires must be a list")
out: list[Requirement] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ManifestError(f"{manifest_path}: requires[{i}] must be an object")
app = item.get("app")
if not isinstance(app, str) or not app or not APP_NAME_RE.match(app):
raise ManifestError(
f"{manifest_path}: requires[{i}].app must be a non-empty lowercase app name"
)
if app == self_name:
raise ManifestError(f"{manifest_path}: requires[{i}].app {app!r} is a self-reference")
if app in seen:
raise ManifestError(f"{manifest_path}: requires has duplicate app {app!r}")
seen.add(app)
on_install = _validate_hook_path(
item.get("on_install"), manifest_path, f"requires[{app}].on_install"
)
on_start = _validate_hook_path(
item.get("on_start"), manifest_path, f"requires[{app}].on_start"
)
out.append(Requirement(app=app, on_install=on_install, on_start=on_start))
return tuple(out)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json. """Parse and validate a manifest.json.
@ -132,6 +189,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: ports must be a list of integers") raise ManifestError(f"{path}: ports must be a list of integers")
settings = _parse_settings(raw.get("settings"), path) settings = _parse_settings(raw.get("settings"), path)
requires = _parse_requires(raw.get("requires"), path, name)
open_url_raw = raw.get("open_url", "") open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str): if not isinstance(open_url_raw, str):
@ -148,4 +206,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
description_long=str(raw.get("description_long", "")), description_long=str(raw.get("description_long", "")),
settings=settings, settings=settings,
open_url=open_url_raw, open_url=open_url_raw,
requires=requires,
) )

View file

@ -1,13 +1,21 @@
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from furtka import dockerops from furtka import deps, dockerops, installer
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
# 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:
kind: str # "ensure_volume" | "compose_up" | "skip" kind: str # "ensure_volume" | "compose_up" | "hook" | "skip" | "error"
target: str target: str
detail: str = "" detail: str = ""
@ -20,13 +28,20 @@ class Action:
def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]: def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
"""Walk the apps tree and bring docker into the desired state. """Walk the apps tree and bring docker into the desired state.
Apps are visited in dependency order providers before consumers so a
consumer's `on_start` hook runs against an already-up provider. Within a
tier, order stays alphabetical for deterministic boot logs. Apps with
unresolvable `requires` (missing provider, broken manifest cycle) are
visited last; reconcile's per-app isolation still kicks in if they fail.
Failures during one app's reconcile (Docker errors, missing binary, …) are Failures during one app's reconcile (Docker errors, missing binary, …) are
captured as Action(kind='error', ) and do NOT abort the whole sweep the captured as Action(kind='error', ) and do NOT abort the whole sweep the
other apps still get reconciled. Callers inspect the returned actions to other apps still get reconciled. Callers inspect the returned actions to
decide overall success. decide overall success.
""" """
actions: list[Action] = [] actions: list[Action] = []
for result in scan(apps_root): results = scan(apps_root)
for result in deps.installed_topo_order(results):
if not result.ok: if not result.ok:
actions.append(Action("skip", result.path.name, result.error or "")) actions.append(Action("skip", result.path.name, result.error or ""))
continue continue
@ -37,6 +52,31 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
actions.append(Action("ensure_volume", full)) actions.append(Action("ensure_volume", full))
if not dry_run: if not dry_run:
dockerops.ensure_volume(full) dockerops.ensure_volume(full)
hook_failed = False
for req in m.requires:
if not req.on_start:
continue
hook_label = f"{m.name}:{req.app}:on_start"
actions.append(Action("hook", hook_label, req.on_start))
if dry_run:
continue
try:
_fire_on_start_hook(m, req, apps_root)
except (
dockerops.DockerError,
FileNotFoundError,
OSError,
ManifestError,
) as e:
actions.append(Action("error", m.name, f"on_start({req.app}): {e}"))
hook_failed = True
break
if hook_failed:
# Skip compose_up: a consumer whose provider's contract didn't
# get re-established (e.g. missing MQTT user) starting up
# blindly is worse than not starting it. The provider stays up
# and other apps in the sweep keep going.
continue
actions.append(Action("compose_up", m.name)) actions.append(Action("compose_up", m.name))
if not dry_run: if not dry_run:
dockerops.compose_up(result.path, m.name) dockerops.compose_up(result.path, m.name)
@ -48,5 +88,56 @@ def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]:
return actions return actions
def _fire_on_start_hook(consumer, req, apps_root: Path) -> None:
"""Run a single `on_start` hook against the provider's running container.
Reconciler-local helper kept narrow on purpose so reconcile's main loop
stays scannable. Errors propagate; the caller decorates with the per-app
Action("error", ...) and skips compose_up for this consumer.
"""
provider_dir = apps_root / req.app
provider_manifest_path = provider_dir / "manifest.json"
if not provider_manifest_path.is_file():
raise FileNotFoundError(f"required app {req.app!r} is not installed")
# Validate provider manifest loads (otherwise scanner would have skipped
# it and we'd still try to exec — fail loud here instead).
load_manifest(provider_manifest_path, expected_name=req.app)
hook_abs = provider_dir / req.on_start
if not hook_abs.is_file():
raise FileNotFoundError(f"on_start hook {req.on_start!r} missing in provider {req.app}")
service = deps.provider_exec_service(provider_dir, req.app)
dockerops.compose_exec_script(
provider_dir,
req.app,
service,
hook_abs,
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: 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

@ -226,12 +226,17 @@ def _link_new_units(unit_dir: Path) -> list[str]:
"""`systemctl link` any unit file in unit_dir that isn't already symlinked """`systemctl link` any unit file in unit_dir that isn't already symlinked
into /etc/systemd/system/. Returns the list of newly-linked unit names. into /etc/systemd/system/. Returns the list of newly-linked unit names.
Newly-linked `.timer` units are additionally `systemctl enable`d so that Newly-linked `.timer` units are additionally `systemctl enable`d *and
a self-update introducing a timer (e.g. 26.5 26.6 adding started* so that a self-update introducing a timer (e.g. 26.5 26.6
furtka-catalog-sync.timer) activates it automatically the installer's adding furtka-catalog-sync.timer, 26.19 26.20 adding
enable list only applies to fresh installs. A linked-but-disabled timer furtka-welcome.timer) activates it right away the installer's enable
never fires on its own, so without this step catalog sync would never list only applies to fresh installs, and `enable` alone only takes
happen on upgraded boxes. effect at the next boot (the bench sat on a dead welcome timer after the
26.20 update).
Units are linked through the `/opt/furtka/current` symlink, not the
versioned directory that `unit_dir` scans, so the link keeps following
later updates and rollbacks the same way the installer-created links do.
""" """
if not unit_dir.is_dir(): if not unit_dir.is_dir():
return [] return []
@ -242,9 +247,10 @@ def _link_new_units(unit_dir: Path) -> list[str]:
target = _SYSTEMD_DIR / unit_file.name target = _SYSTEMD_DIR / unit_file.name
if target.exists() or target.is_symlink(): if target.exists() or target.is_symlink():
continue continue
_run(["systemctl", "link", str(unit_file)]) _run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
if unit_file.suffix == ".timer": if unit_file.suffix == ".timer":
_run(["systemctl", "enable", unit_file.name]) _run(["systemctl", "enable", unit_file.name])
_run(["systemctl", "start", unit_file.name])
linked.append(unit_file.name) linked.append(unit_file.name)
return linked return linked

View file

@ -27,13 +27,15 @@ The build starts from Arch's stock `releng` profile (the same one used to build
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time | | `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
| `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` | | `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` |
| `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` | | `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` |
| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner also shows the DHCP-assigned IP as a fallback URL | | `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner always carries a fallback line: the DHCP-assigned IP(s), or a "no IP yet — check cable / DHCP" hint until one arrives. Only touches the file (and `agetty --reload`s) when the text changed |
| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot | | `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (banner-updater, first run at boot) in `multi-user.target.wants/`, and `furtka-issue.timer` (re-runs the updater every 5 s) in `timers.target.wants/` |
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN. The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all. mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all.
`build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so.
## Test flow ## Test flow
1. Build: `./iso/build.sh` 1. Build: `./iso/build.sh`
@ -49,6 +51,27 @@ mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live
5. Find its IP in Proxmox's VM summary (or your router's DHCP table) 5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there 6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
## Real hardware (what differs from the VM flow)
Learned on the first bare-metal run, see [docs/hardware-test-2026-08.md](../docs/hardware-test-2026-08.md):
- **BIOS boxes work.** The hybrid ISO boots via syslinux on legacy firmware
(you'll see the stock Arch splash — only the menu labels are branded), and
the installer detects the missing `/sys/firmware/efi` and picks GRUB + MBR.
- **Pull the USB stick before "restart now".** Most BIOSes boot USB before
the disk; the done page says so above the button since 26.20-alpha.
- **Disks with a previous OS are fine since 26.20-alpha.** The live ISO
auto-activates old LVM/RAID/swap; `webinstaller/diskprep.py` releases the
disk before archinstall partitions it. On 26.19 and earlier you'd hit
`unable to inform the kernel of the change … in use` at 12 % — clear it
from the tty1 root shell with `dmsetup remove <node>` for every dm node
shown by `lsblk`, then `wipefs -a /dev/sdX` and retry.
- **Slow NICs.** The console banner's IP fallback line is timer-driven
since 26.20-alpha; on 26.19 it only appeared if DHCP beat getty.
- **Shell on the live ISO**: since 26.20-alpha tty1 stays at the login
prompt (banner refreshes live); log in as `root`, no password. sshd runs;
set a root password with `passwd` to reach it over the network.
## What you see after install + reboot ## What you see after install + reboot
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead: Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:

View file

@ -45,6 +45,15 @@ cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/"
# releng auto-logs root in on tty1. That turns our /etc/issue banner into a
# one-shot snapshot: once agetty has handed tty1 to a shell, `agetty --reload`
# from furtka-update-issue has nothing left to redraw, so the IP fallback only
# shows if DHCP happened to beat getty. Drop the drop-in: tty1 stays at the
# login prompt with a live-refreshed banner (furtka-issue.timer), and a shell
# is one `root` + Enter away (releng's root has no password) — the banner says
# so.
rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d"
echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# releng ships menu entries labelled "Arch Linux install medium" across three # releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to

View file

@ -2,5 +2,6 @@
Furtka Live Installer starting… Furtka Live Installer starting…
Once ready, open http://proksi.local:5000 on another device Once ready, open http://proksi.local:5000 on another device
on your network. The exact URL will appear below. on your network. The exact URL (and an IP fallback) will
appear here in a few seconds.

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome Description=Write Furtka /etc/issue with current IP for the console welcome
After=network-online.target # No network-online dependency on purpose: the first run should paint the
Wants=network-online.target # banner (with a "no IP yet" fallback) as early as possible; the timer
# re-runs it until an address shows up.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -0,0 +1,10 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -0,0 +1 @@
../furtka-issue.timer

View file

@ -2,22 +2,40 @@
# Regenerates /etc/issue so the live-ISO console tells the user which URL # Regenerates /etc/issue so the live-ISO console tells the user which URL
# to open in their browser. Shows proksi.local (via avahi/mDNS) as the # to open in their browser. Shows proksi.local (via avahi/mDNS) as the
# preferred URL and the raw IP as a fallback for networks where mDNS # preferred URL and the raw IP as a fallback for networks where mDNS
# doesn't work. Reload at the end nudges agetty to redraw. # doesn't work.
set -e #
# Runs at boot and then every few seconds from furtka-issue.timer, so the
# fallback line is *always* on screen: with the address once DHCP has
# handed one out, or with a "no IP yet" hint before that / when the cable
# is unplugged. Only rewrites + nudges agetty when the text actually
# changed, so the login prompt doesn't flicker on every tick.
set -u
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) issue=/etc/issue
{ mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
echo echo
echo " Open Furtka in a browser on another device on your network:" echo " Open Furtka in a browser on another device on your network:"
echo echo
echo " http://proksi.local:5000 (easy — try this first)" echo " http://proksi.local:5000 (easy — try this first)"
if [ -n "$ip" ]; then if ((${#ips[@]})); then
echo " http://${ip}:5000 (fallback if the first doesn't work)" for ip in "${ips[@]}"; do
echo " http://${ip}:5000 (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo echo
} > /etc/issue echo " Need a shell? Log in as root (no password)."
echo
)
agetty --reload 2>/dev/null || true if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -29,6 +29,22 @@ server {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;
} }
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
location /api/poll/ {
# Public traffic arrives from the edge proxy via 192.168.178.164.
# Only that hop is trusted when unwinding X-Forwarded-For, so a
# client-supplied X-Forwarded-For cannot forge a fresh "device".
set_real_ip_from 192.168.178.164;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
limit_req zone=poll burst=10 nodelay;
proxy_pass http://127.0.0.1:8090/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header User-Agent $http_user_agent;
proxy_read_timeout 5s;
}
location = /favicon.svg { location = /favicon.svg {
access_log off; access_log off;
log_not_found off; log_not_found off;

View file

@ -0,0 +1,2 @@
# Rate limit for the poll API (http context; included from conf.d).
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;

View file

@ -16,6 +16,7 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT" install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF" cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf
ln -sfn "$SITE_CONF" "$SITE_LINK" ln -sfn "$SITE_CONF" "$SITE_LINK"
# Drop the Ubuntu default site so it doesn't shadow us on :80. # Drop the Ubuntu default site so it doesn't shadow us on :80.

View file

@ -0,0 +1,17 @@
[Unit]
Description=furtka.org poll counter
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py
DynamicUser=yes
StateDirectory=furtka-poll
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

172
ops/poll/pollsvc.py Normal file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Tiny cookie-free poll counter for furtka.org.
Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies
beyond the standard library.
GET /<poll> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
POST /<poll> -> body {"choice": "m"}; answers like GET plus "yours"
A device is identified by a salted SHA-256 of client IP (as resolved by
nginx from the trusted edge proxy) + User-Agent, so nobody needs a cookie
and the raw address is never stored. Voting again
from the same device changes the vote instead of adding one.
"""
import hashlib
import json
import logging
import os
import sqlite3
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
POLLS = {
"logo": ("m", "r"),
}
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll")
DB_PATH = os.path.join(STATE_DIR, "votes.db")
SALT_PATH = os.path.join(STATE_DIR, "salt")
LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1")
PORT = int(os.environ.get("POLL_PORT", "8090"))
MAX_BODY = 512
log = logging.getLogger("pollsvc")
_lock = threading.Lock()
def _salt() -> bytes:
try:
with open(SALT_PATH, "rb") as f:
s = f.read()
if len(s) >= 16:
return s
except FileNotFoundError:
pass
s = os.urandom(32)
with open(SALT_PATH, "wb") as f:
f.write(s)
os.chmod(SALT_PATH, 0o600)
return s
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS votes ("
" poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL,"
" ts INTEGER NOT NULL DEFAULT (unixepoch()),"
" PRIMARY KEY (poll, device))"
)
conn.commit()
return conn
SALT = None
CONN = None
def counts(poll: str) -> dict:
with _lock:
rows = CONN.execute(
"SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,)
).fetchall()
c = {k: 0 for k in POLLS[poll]}
for choice, n in rows:
if choice in c:
c[choice] = n
return {"poll": poll, "counts": c, "total": sum(c.values())}
class Handler(BaseHTTPRequestHandler):
server_version = "furtka-poll/1"
def log_message(self, fmt, *args): # quiet; nginx has the access log
log.debug(fmt, *args)
def _client_ip(self) -> str:
# nginx resolves the trusted proxy chain (real_ip module, see
# ops/nginx/furtka.org.conf) and hands us the result as X-Real-IP.
# We never parse X-Forwarded-For here: its leading entries are
# whatever the client chose to send.
return self.headers.get("X-Real-IP") or self.client_address[0]
def _device(self) -> str:
ua = self.headers.get("User-Agent", "")
h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode())
return h.hexdigest()[:32]
def _json(self, status: int, payload: dict):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _poll(self):
name = self.path.strip("/").split("?")[0]
if name not in POLLS:
self._json(404, {"error": "no such poll"})
return None
return name
def do_GET(self):
poll = self._poll()
if not poll:
return
payload = counts(poll)
with _lock:
row = CONN.execute(
"SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device())
).fetchone()
payload["yours"] = row[0] if row else None
self._json(200, payload)
def do_POST(self):
poll = self._poll()
if not poll:
return
try:
n = int(self.headers.get("Content-Length", "0"))
if n > MAX_BODY:
raise ValueError("body too large")
data = json.loads(self.rfile.read(n) or b"{}")
choice = str(data.get("choice", ""))
except (ValueError, json.JSONDecodeError):
self._json(400, {"error": "bad request"})
return
if choice not in POLLS[poll]:
self._json(400, {"error": "unknown choice"})
return
with _lock:
CONN.execute(
"INSERT INTO votes (poll, device, choice) VALUES (?,?,?)"
" ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()",
(poll, self._device(), choice),
)
CONN.commit()
payload = counts(poll)
payload["yours"] = choice
self._json(200, payload)
def main():
global SALT, CONN
logging.basicConfig(
level=os.environ.get("POLL_LOGLEVEL", "INFO"),
stream=sys.stderr,
format="%(levelname)s %(message)s",
)
os.makedirs(STATE_DIR, exist_ok=True)
SALT = _salt()
CONN = _db()
srv = ThreadingHTTPServer((LISTEN, PORT), Handler)
log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH)
srv.serve_forever()
if __name__ == "__main__":
main()

26
ops/poll/setup-poll.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Install / update the poll counter on forge-runner-01. Idempotent.
#
# Usage (on the VM, with sudo):
# sudo ops/poll/setup-poll.sh # install / update
# sudo ops/poll/setup-poll.sh --reset # ... and wipe all votes
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "--reset" ]; then
systemctl stop furtka-poll 2>/dev/null || true
rm -f /var/lib/furtka-poll/votes.db
echo "votes wiped"
fi
install -d -m 0755 /opt/furtka-poll
install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py
install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service
systemctl daemon-reload
systemctl enable --now furtka-poll
systemctl restart furtka-poll
sleep 1
systemctl --no-pager --lines=3 status furtka-poll
curl -fsS http://127.0.0.1:8090/logo && echo
echo "OK: furtka-poll running on 127.0.0.1:8090"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.16-alpha" version = "26.20-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

@ -40,6 +40,7 @@ def fake_dirs(tmp_path, monkeypatch):
# /run/furtka/install.lock by default — redirect into tmp_path so # /run/furtka/install.lock by default — redirect into tmp_path so
# test code doesn't need root. # test code doesn't need root.
monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json")) monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json"))
monkeypatch.setenv("FURTKA_INSTALL_PLAN", str(tmp_path / "install-plan.json"))
monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock")) monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock"))
# install_runner caches env vars at import time, so reload it to # install_runner caches env vars at import time, so reload it to
# pick up the tmp-path env vars this fixture just set. # pick up the tmp-path env vars this fixture just set.
@ -258,6 +259,136 @@ def test_remove_endpoint_happy_path(fake_dirs, no_docker, no_systemd_run):
assert not (apps / "fileshare").exists() assert not (apps / "fileshare").exists()
# --- Dependency plan + confirm flow ----------------------------------------
def test_install_plan_endpoint_lone_app(fake_dirs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
status, body = api._do_install_plan("fileshare")
assert status == 200
assert body["target"] == "fileshare"
assert body["to_install"] == ["fileshare"]
assert body["install_order"] == ["fileshare"]
def test_install_plan_endpoint_with_provider(fake_dirs):
_, bundled = fake_dirs
_write_bundled(
bundled,
"mosquitto",
manifest=dict(VALID_MANIFEST, name="mosquitto"),
env_example="A=real",
)
consumer = dict(
VALID_MANIFEST,
name="zigbee2mqtt",
requires=[{"app": "mosquitto"}],
)
_write_bundled(bundled, "zigbee2mqtt", manifest=consumer, env_example="A=real")
status, body = api._do_install_plan("zigbee2mqtt")
assert status == 200
assert body["install_order"] == ["mosquitto", "zigbee2mqtt"]
assert body["to_install"] == ["mosquitto", "zigbee2mqtt"]
assert body["already_installed"] == []
assert [s["name"] for s in body["summaries"]] == ["mosquitto", "zigbee2mqtt"]
def test_install_plan_endpoint_rejects_cycle(fake_dirs):
_, bundled = fake_dirs
_write_bundled(bundled, "a", manifest=dict(VALID_MANIFEST, name="a", requires=[{"app": "b"}]))
_write_bundled(bundled, "b", manifest=dict(VALID_MANIFEST, name="b", requires=[{"app": "a"}]))
status, body = api._do_install_plan("a")
assert status == 400
assert "circular" in body["error"]
def test_install_endpoint_without_confirm_returns_409_for_transitive(fake_dirs):
_, bundled = fake_dirs
_write_bundled(
bundled,
"mosquitto",
manifest=dict(VALID_MANIFEST, name="mosquitto"),
env_example="A=real",
)
consumer = dict(VALID_MANIFEST, name="zigbee2mqtt", requires=[{"app": "mosquitto"}])
_write_bundled(bundled, "zigbee2mqtt", manifest=consumer, env_example="A=real")
status, body = api._do_install("zigbee2mqtt")
assert status == 409
assert "additional apps required" in body["error"]
assert body["to_install"] == ["mosquitto", "zigbee2mqtt"]
def test_install_endpoint_with_confirm_dispatches_plan(fake_dirs, no_docker, no_systemd_run):
apps, bundled = fake_dirs
_write_bundled(
bundled,
"mosquitto",
manifest=dict(VALID_MANIFEST, name="mosquitto"),
env_example="A=real",
)
consumer = dict(VALID_MANIFEST, name="zigbee2mqtt", requires=[{"app": "mosquitto"}])
_write_bundled(bundled, "zigbee2mqtt", manifest=consumer, env_example="A=real")
status, body = api._do_install("zigbee2mqtt", confirm_dependencies=True)
assert status == 202
# Both apps installed in plan order.
assert (apps / "mosquitto").exists()
assert (apps / "zigbee2mqtt").exists()
# Plan file written for the background runner.
import json as _json
plan = _json.loads(api.install_runner.plan_path().read_text())
assert plan["target"] == "zigbee2mqtt"
assert plan["to_install"] == ["mosquitto", "zigbee2mqtt"]
def test_install_endpoint_lone_target_skips_confirm(fake_dirs, no_docker, no_systemd_run):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
status, body = api._do_install("fileshare")
# No transitive deps, so no 409 — straight to 202.
assert status == 202
def test_remove_blocked_when_other_app_depends(fake_dirs, no_docker, no_systemd_run):
apps, bundled = fake_dirs
_write_bundled(
bundled,
"mosquitto",
manifest=dict(VALID_MANIFEST, name="mosquitto"),
env_example="A=real",
)
consumer = dict(VALID_MANIFEST, name="zigbee2mqtt", requires=[{"app": "mosquitto"}])
_write_bundled(bundled, "zigbee2mqtt", manifest=consumer, env_example="A=real")
api._do_install("zigbee2mqtt", confirm_dependencies=True)
status, body = api._do_remove("mosquitto")
assert status == 409
assert body["dependents"] == ["zigbee2mqtt"]
assert "required by" in body["error"]
# mosquitto is still installed — guard refused to remove it.
assert (apps / "mosquitto").exists()
def test_remove_succeeds_when_dependent_first_removed(fake_dirs, no_docker, no_systemd_run):
apps, bundled = fake_dirs
_write_bundled(
bundled,
"mosquitto",
manifest=dict(VALID_MANIFEST, name="mosquitto"),
env_example="A=real",
)
consumer = dict(VALID_MANIFEST, name="zigbee2mqtt", requires=[{"app": "mosquitto"}])
_write_bundled(bundled, "zigbee2mqtt", manifest=consumer, env_example="A=real")
api._do_install("zigbee2mqtt", confirm_dependencies=True)
# Remove consumer first — should succeed.
status, _ = api._do_remove("zigbee2mqtt")
assert status == 200
# Now mosquitto has no dependents, so remove succeeds too.
status, _ = api._do_remove("mosquitto")
assert status == 200
assert not (apps / "mosquitto").exists()
def _request(port, path, cookie=None, method="GET", body=None): def _request(port, path, cookie=None, method="GET", body=None):
headers = {} headers = {}
if cookie is not None: if cookie is not None:

View file

@ -195,3 +195,34 @@ def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
"groups": [], "groups": [],
} }
] ]
def test_log_page_done_state_shows_usb_step_before_restart(monkeypatch, tmp_path):
"""The 'pull the USB stick' step must be visible *before* the restart
button after the click the box reboots within seconds (first hardware
bench booted the stick again)."""
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Installation completed without any errors.\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
app_module.settings["hostname"] = "bench"
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert '<ol class="next-steps">' in html
assert "http://bench.local" in html
assert "USB stick is out" in html
assert html.index("next-steps") < html.index("USB stick is out")
def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
import app as app_module
log_path = tmp_path / "install.log"
log_path.write_text("Creating partitions: /dev/sda\n")
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "next-steps" not in html
assert "install_reboot" not in html and "restart now" not in html

View file

@ -331,3 +331,21 @@ def test_write_and_read_state_round_trip(catalog):
assert s["stage"] == "downloading" assert s["stage"] == "downloading"
assert s["latest"] == "26.6" assert s["latest"] == "26.6"
assert "updated_at" in s assert "updated_at" in s
def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
"""A boot-time network failure must not leave the state file on
'checking' forever the UI would show 'checking…' with nothing running."""
from furtka import _release_common as _rc
def offline(host, repo, path, *, error_cls=RuntimeError):
raise error_cls("forgejo api https://x/releases: Network is unreachable")
monkeypatch.setattr(_rc, "forgejo_api", offline)
with pytest.raises(catalog.CatalogError, match="Network is unreachable"):
catalog.sync_catalog()
state = catalog.read_state()
assert state["stage"] == "error"
assert "Network is unreachable" in state["error"]

View file

@ -103,3 +103,98 @@ def test_app_install_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
err = capsys.readouterr().err err = capsys.readouterr().err
assert "install-bg failed" in err assert "install-bg failed" in err
assert "compose pull failed" in err assert "compose pull failed" in err
# --- Dependency-aware install + remove ---------------------------------------
def _write_manifest(root, name, **overrides):
app = root / name
app.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"display_name": name,
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
**overrides,
}
(app / "manifest.json").write_text(json.dumps(payload))
(app / "docker-compose.yaml").write_text("services: {}\n")
return app
def test_app_remove_blocked_by_dependent(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
_write_manifest(tmp_path, "mosquitto")
_write_manifest(tmp_path, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
rc = main(["app", "remove", "mosquitto"])
assert rc == 2
err = capsys.readouterr().err
assert "required by: zigbee2mqtt" in err
def test_app_remove_unblocked_when_no_dependents(tmp_path, monkeypatch):
_set_env(monkeypatch, tmp_path)
_write_manifest(tmp_path, "mosquitto")
from furtka import dockerops
monkeypatch.setattr(dockerops, "compose_down", lambda *a, **k: None)
rc = main(["app", "remove", "mosquitto"])
assert rc == 0
assert not (tmp_path / "mosquitto").exists()
def test_app_install_uses_plan_for_named_install(tmp_path, monkeypatch, capsys):
"""Named install pulls in dependencies via plan_install."""
_set_env(monkeypatch, tmp_path)
bundled = tmp_path / "bundled"
bundled.mkdir()
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
# No catalog dir — bundled-only.
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
_write_manifest(bundled, "mosquitto")
_write_manifest(bundled, "zigbee2mqtt", requires=[{"app": "mosquitto"}])
from furtka import install_runner, installer
# Stub install_from so we don't actually copy files / mess with placeholders.
install_calls: list[str] = []
def fake_install_from(src, settings=None):
install_calls.append(src.name)
return tmp_path / src.name
monkeypatch.setattr(installer, "install_from", fake_install_from)
monkeypatch.setattr(install_runner, "_INSTALL_PLAN", tmp_path / "install-plan.json")
# Named installs drive the docker phase through run_install (so on_install
# hooks fire) — stub it and assert it's invoked with the chosen target.
run_calls: list[str] = []
monkeypatch.setattr(install_runner, "run_install", lambda name: run_calls.append(name))
rc = main(["app", "install", "zigbee2mqtt"])
assert rc == 0
# Provider installed before consumer.
assert install_calls == ["mosquitto", "zigbee2mqtt"]
# The docker phase (which fires on_install hooks) ran for the target.
assert run_calls == ["zigbee2mqtt"]
# And the plan file was staged for run_install to consume.
plan = json.loads(install_runner.plan_path().read_text())
assert plan["target"] == "zigbee2mqtt"
assert plan["to_install"] == ["mosquitto", "zigbee2mqtt"]
def test_app_install_named_with_cycle_exits_2(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
bundled = tmp_path / "bundled"
bundled.mkdir()
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
_write_manifest(bundled, "a", requires=[{"app": "b"}])
_write_manifest(bundled, "b", requires=[{"app": "a"}])
rc = main(["app", "install", "a"])
assert rc == 2
err = capsys.readouterr().err
assert "circular" in err.lower()

183
tests/test_deps.py Normal file
View file

@ -0,0 +1,183 @@
import json
import pytest
from furtka import deps
BASE_MANIFEST = {
"display_name": "X",
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
}
@pytest.fixture
def apps_root(tmp_path, monkeypatch):
"""Three roots: installed, catalog, bundled. Each set up empty by default."""
installed = tmp_path / "installed"
catalog = tmp_path / "catalog" / "apps"
bundled = tmp_path / "bundled"
for p in (installed, catalog, bundled):
p.mkdir(parents=True)
monkeypatch.setenv("FURTKA_APPS_DIR", str(installed))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
return {"installed": installed, "catalog": catalog, "bundled": bundled}
def _write_manifest(root, name, **overrides):
app = root / name
app.mkdir(parents=True, exist_ok=True)
payload = dict(BASE_MANIFEST, name=name, **overrides)
(app / "manifest.json").write_text(json.dumps(payload))
return app
def test_plan_install_no_deps(apps_root):
_write_manifest(apps_root["catalog"], "alone")
plan = deps.plan_install("alone")
assert plan.target == "alone"
assert plan.install_order == ("alone",)
assert plan.to_install == ("alone",)
assert plan.already_installed == frozenset()
def test_plan_install_linear_chain(apps_root):
# A requires B, B requires C — all in catalog, none installed yet.
_write_manifest(apps_root["catalog"], "c")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("c", "b", "a")
assert plan.to_install == ("c", "b", "a")
def test_plan_install_diamond(apps_root):
# A requires B and C; B requires D; C requires D. D must appear once,
# before B and C, which come before A.
_write_manifest(apps_root["catalog"], "d")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}, {"app": "c"}])
plan = deps.plan_install("a")
order = plan.install_order
# D first, A last, B and C in between (deterministically alphabetical).
assert order[0] == "d"
assert order[-1] == "a"
assert set(order[1:-1]) == {"b", "c"}
assert order.count("d") == 1
def test_plan_install_already_installed_provider(apps_root):
_write_manifest(apps_root["installed"], "b") # provider already installed
_write_manifest(apps_root["catalog"], "b")
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("b", "a")
assert plan.to_install == ("a",)
assert plan.already_installed == frozenset({"b"})
def test_plan_install_cycle_two_node(apps_root):
# Manifest validator already rejects self-reference at load time, but
# mutual references (A -> B -> A) only show up at plan time.
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="circular"):
deps.plan_install("a")
def test_plan_install_cycle_three_node(apps_root):
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="a -> b -> c -> a"):
deps.plan_install("a")
def test_plan_install_missing_provider(apps_root):
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "ghost"}])
with pytest.raises(deps.DependencyError, match="ghost"):
deps.plan_install("a")
def test_plan_install_prefers_installed_over_catalog(apps_root):
# If a provider exists in both installed and catalog, we resolve via
# installed (so we read the actual on-disk manifest the user has).
_write_manifest(apps_root["installed"], "b")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "extra"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
# The installed manifest has no requires, so "extra" is NOT pulled in.
assert plan.install_order == ("b", "a")
def test_dependents_of_empty(apps_root):
assert deps.dependents_of("anything") == ()
def test_dependents_of_finds_consumers(apps_root):
_write_manifest(apps_root["installed"], "x")
_write_manifest(apps_root["installed"], "a", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "b", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "unrelated")
assert deps.dependents_of("x") == ("a", "b")
assert deps.dependents_of("unrelated") == ()
def test_installed_topo_order_preserves_alpha_when_independent(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "alpha")
_write_manifest(apps_root["installed"], "bravo")
_write_manifest(apps_root["installed"], "charlie")
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
assert [r.manifest.name for r in ordered] == ["alpha", "bravo", "charlie"]
def test_installed_topo_order_puts_providers_first(apps_root):
from furtka.scanner import scan
# Alphabetically z2m comes before mqtt? No — but let's force the
# dependency to win. consumer=alpha requires=provider=zulu, so naive
# alpha order would put alpha first. Topo must flip them.
_write_manifest(apps_root["installed"], "zulu")
_write_manifest(apps_root["installed"], "alpha", requires=[{"app": "zulu"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
assert names == ["zulu", "alpha"]
def test_installed_topo_order_missing_provider_tails_app(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "good")
_write_manifest(apps_root["installed"], "needy", requires=[{"app": "ghost"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
# `good` first (no deps), `needy` last (unresolved).
assert names == ["good", "needy"]
def test_provider_exec_service_picks_first_service(apps_root, monkeypatch):
from furtka import dockerops
monkeypatch.setattr(
dockerops,
"compose_image_tags",
lambda app_dir, project: {"server": "img:1", "worker": "img:2"},
)
assert deps.provider_exec_service(apps_root["installed"] / "x", "x") == "server"
def test_provider_exec_service_falls_back_to_project_on_docker_error(apps_root, monkeypatch):
from furtka import dockerops
def boom(app_dir, project):
raise dockerops.DockerError("docker not running")
monkeypatch.setattr(dockerops, "compose_image_tags", boom)
assert deps.provider_exec_service(apps_root["installed"] / "x", "myproj") == "myproj"

146
tests/test_diskprep.py Normal file
View file

@ -0,0 +1,146 @@
"""release_disk() against a recorded `lsblk -P` picture of the first
hardware bench: an SSD with an old Proxmox VE `pve` volume group stacked on
sda3 (swap, root, thin pool). Commands are captured through the injected
runner nothing touches real devices."""
import subprocess
from pathlib import Path
import pytest
from diskprep import release_disk
# What the bench looked like before cleanup (lsblk -lnP -o NAME,TYPE,MOUNTPOINTS /dev/sda).
BENCH_BEFORE = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
NAME="pve-swap" TYPE="lvm" MOUNTPOINTS="[SWAP]"
NAME="pve-root" TYPE="lvm" MOUNTPOINTS="/mnt/old"
NAME="pve-data_tmeta" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data_tdata" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
"""
BENCH_CLEAN = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
"""
EMPTY = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
"""
class FakeRunner:
"""Answers lsblk from a script of snapshots (one per call, last one
sticks) and records everything else."""
def __init__(self, lsblk_outputs):
self.lsblk_outputs = list(lsblk_outputs)
self.calls = []
def __call__(self, cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
if len(self.lsblk_outputs) > 1:
out = self.lsblk_outputs.pop(0)
else:
out = self.lsblk_outputs[0]
return subprocess.CompletedProcess(cmd, 0, out, "")
self.calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, "", "")
def test_release_tears_down_lvm_stack_leaves_first():
# lsblk #1 (inventory) and #2 (first removal pass) see the full stack;
# everything after that is clean — the dm removals "worked".
runner = FakeRunner([BENCH_BEFORE, BENCH_BEFORE, BENCH_CLEAN])
lines = release_disk("/dev/sda", run=runner)
calls = runner.calls
assert ["swapoff", "/dev/pve-swap"] in calls
assert ["umount", "--all-targets", "/dev/pve-root"] in calls
removed = [c[2] for c in calls if c[:2] == ["dmsetup", "remove"]]
# Thin LV before its pool, pool before tmeta/tdata.
assert removed.index("pve-data") < removed.index("pve-data-tpool")
assert removed.index("pve-data-tpool") < removed.index("pve-data_tmeta")
assert set(removed) == {
"pve-swap",
"pve-root",
"pve-data_tmeta",
"pve-data-tpool",
"pve-data",
"pve-data_tdata",
}
assert ["wipefs", "-a", "/dev/sda3"] in calls
assert ["wipefs", "-a", "/dev/sda1"] in calls
assert ["wipefs", "-a", "/dev/sda"] in calls
assert calls[-1] == ["blockdev", "--rereadpt", "/dev/sda"]
assert not any("WARNING" in line for line in lines)
assert lines[0].startswith("Releasing /dev/sda")
def test_release_warns_when_stack_survives():
runner = FakeRunner([BENCH_BEFORE]) # never becomes clean
lines = release_disk("/dev/sda", run=runner)
assert any("WARNING: still in use" in line for line in lines)
# Still tries to give archinstall a chance.
assert ["blockdev", "--rereadpt", "/dev/sda"] in runner.calls
def test_release_empty_disk_only_wipes_and_rereads():
runner = FakeRunner([EMPTY])
lines = release_disk("sda", run=runner) # bare name is accepted too
assert runner.calls == [["wipefs", "-a", "/dev/sda"], ["blockdev", "--rereadpt", "/dev/sda"]]
assert any("nothing stacked" in line for line in lines)
def test_release_survives_missing_tools():
def runner(cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
return subprocess.CompletedProcess(cmd, 0, BENCH_BEFORE, "")
raise FileNotFoundError(cmd[0])
lines = release_disk("/dev/sda", run=runner)
assert any("command not found" in line for line in lines)
@pytest.fixture
def app_module(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path / "state")
return app_module
def test_install_run_releases_disk_before_spawning(app_module, monkeypatch):
order = []
def fake_release(disk):
order.append(("release", disk))
return ["Releasing X"]
monkeypatch.setattr(app_module, "release_disk", fake_release)
monkeypatch.setattr(app_module, "spawn_archinstall", lambda *a: order.append(("spawn",)))
monkeypatch.setattr(app_module, "build_archinstall_config", lambda s: {})
monkeypatch.setattr(app_module, "build_archinstall_creds", lambda s: {})
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
app_module.settings.update(boot_drive="/dev/sda", username="u", password="pw12345678")
client = app_module.app.test_client()
resp = client.post("/install/run")
assert resp.status_code == 302
assert order == [("release", "/dev/sda"), ("spawn",)]
assert Path(app_module.INSTALL_LOG).read_text().startswith("Releasing X")
def test_progress_names_disk_in_use_failure(app_module):
log = (
"Traceback (most recent call last):\n"
"_ped.IOException: ... unable to inform the kernel of the change ..."
)
progress = app_module.parse_install_progress(log)
assert progress["status"] == "error"
assert "still in use" in progress["phase"]

116
tests/test_dockerops.py Normal file
View file

@ -0,0 +1,116 @@
import subprocess
import pytest
from furtka import dockerops
class FakeProc:
def __init__(self, stdout=b"", stderr=b"", returncode=0):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def test_compose_exec_builds_command(tmp_path, monkeypatch):
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
recorded["kwargs"] = kwargs
return FakeProc(stdout="ok\n", returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
out = dockerops.compose_exec(tmp_path, "myproj", "svc", ["echo", "hi"])
assert out == "ok\n"
cmd = recorded["cmd"]
# docker compose --project-name myproj --file <path>/docker-compose.yaml exec -T svc echo hi
assert cmd[0] == "docker"
assert cmd[1] == "compose"
assert "--project-name" in cmd and "myproj" in cmd
assert "exec" in cmd
assert "-T" in cmd
# -T must come before the service name
assert cmd.index("-T") < cmd.index("svc")
# argv appended after service
assert cmd[-2:] == ["echo", "hi"]
def test_compose_exec_propagates_env(tmp_path, monkeypatch):
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
return FakeProc()
monkeypatch.setattr(subprocess, "run", fake_run)
dockerops.compose_exec(tmp_path, "p", "s", ["true"], env={"A": "1", "B": "two"})
cmd = recorded["cmd"]
# `--env A=1 --env B=two` should appear before the service name.
s_idx = cmd.index("s")
env_args = cmd[:s_idx]
assert env_args.count("--env") == 2
assert "A=1" in env_args
assert "B=two" in env_args
def test_compose_exec_raises_on_nonzero(tmp_path, monkeypatch):
def fake_run(cmd, **kwargs):
return FakeProc(stdout="", stderr="boom", returncode=2)
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="exited 2"):
dockerops.compose_exec(tmp_path, "p", "s", ["fail"])
def test_compose_exec_raises_on_timeout(tmp_path, monkeypatch):
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="timed out"):
dockerops.compose_exec(tmp_path, "p", "s", ["sleep", "9999"], timeout=1)
def test_compose_exec_script_streams_via_stdin(tmp_path, monkeypatch):
script = tmp_path / "hook.sh"
body = b"#!/bin/sh\necho hello\n"
script.write_bytes(body)
recorded = {}
def fake_run(cmd, **kwargs):
recorded["cmd"] = cmd
recorded["input"] = kwargs["input"]
return FakeProc(stdout=b"hello\n", returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
out = dockerops.compose_exec_script(tmp_path, "p", "s", script)
assert out == "hello\n"
# exec ... s sh -s (script body comes in on stdin)
cmd = recorded["cmd"]
assert cmd[-3:] == ["s", "sh", "-s"]
assert recorded["input"] == body
def test_compose_exec_script_raises_on_nonzero(tmp_path, monkeypatch):
script = tmp_path / "fail.sh"
script.write_bytes(b"exit 1\n")
def fake_run(cmd, **kwargs):
return FakeProc(stdout=b"", stderr=b"hook says no", returncode=1)
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="hook fail.sh exited 1"):
dockerops.compose_exec_script(tmp_path, "p", "s", script)
def test_compose_exec_script_raises_on_timeout(tmp_path, monkeypatch):
script = tmp_path / "slow.sh"
script.write_bytes(b"sleep 10\n")
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
monkeypatch.setattr(subprocess, "run", fake_run)
with pytest.raises(dockerops.DockerError, match="hook slow.sh timed out"):
dockerops.compose_exec_script(tmp_path, "p", "s", script, timeout=1)

View file

@ -21,6 +21,7 @@ def runner(tmp_path, monkeypatch):
apps.mkdir() apps.mkdir()
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps)) monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json")) monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json"))
monkeypatch.setenv("FURTKA_INSTALL_PLAN", str(tmp_path / "install-plan.json"))
monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock")) monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock"))
import importlib import importlib
@ -33,7 +34,7 @@ def runner(tmp_path, monkeypatch):
return r return r
def _write_installed_app(apps_dir: Path, name: str = "fileshare"): def _write_installed_app(apps_dir: Path, name: str = "fileshare", **overrides):
app = apps_dir / name app = apps_dir / name
app.mkdir() app.mkdir()
manifest = { manifest = {
@ -44,6 +45,7 @@ def _write_installed_app(apps_dir: Path, name: str = "fileshare"):
"volumes": ["files"], "volumes": ["files"],
"ports": [445], "ports": [445],
"icon": "icon.svg", "icon": "icon.svg",
**overrides,
} }
(app / "manifest.json").write_text(json.dumps(manifest)) (app / "manifest.json").write_text(json.dumps(manifest))
(app / "docker-compose.yaml").write_text("services: {}\n") (app / "docker-compose.yaml").write_text("services: {}\n")
@ -175,3 +177,304 @@ def test_run_install_releases_lock_after_error(runner, monkeypatch):
fh = runner.acquire_lock() fh = runner.acquire_lock()
fh.close() fh.close()
# --- plan-aware multi-app installs -------------------------------------------
def _write_plan(plan_path: Path, target: str, to_install: list[str]) -> None:
plan_path.write_text(json.dumps({"target": target, "to_install": to_install}))
def _stub_docker_ops(monkeypatch, calls: list):
import furtka.dockerops as dockerops
def _pull(app_dir, project):
calls.append(("pull", project))
def _vol(name):
calls.append(("vol", name))
def _up(app_dir, project):
calls.append(("up", project))
monkeypatch.setattr(dockerops, "compose_pull", _pull)
monkeypatch.setattr(dockerops, "ensure_volume", _vol)
monkeypatch.setattr(dockerops, "compose_up", _up)
def test_run_install_iterates_plan_order(runner, monkeypatch):
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
_write_installed_app(
apps_dir(),
"zigbee2mqtt",
requires=[{"app": "mosquitto"}],
)
_write_plan(runner.plan_path(), "zigbee2mqtt", ["mosquitto", "zigbee2mqtt"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
runner.run_install("zigbee2mqtt")
# mosquitto fully reconciled before zigbee2mqtt starts.
assert [c for c in calls if c[0] == "pull"] == [("pull", "mosquitto"), ("pull", "zigbee2mqtt")]
assert [c for c in calls if c[0] == "up"] == [("up", "mosquitto"), ("up", "zigbee2mqtt")]
s = runner.read_state()
assert s["stage"] == "done"
assert s["target"] == "zigbee2mqtt"
assert s["app"] == "zigbee2mqtt"
def test_run_install_fires_on_install_hook_against_provider(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
mosq = _write_installed_app(apps_dir(), "mosquitto")
# Provider ships a hook script.
(mosq / "hooks").mkdir()
hook = mosq / "hooks" / "create-user.sh"
hook.write_bytes(b"#!/bin/sh\necho MQTT_USER=z2m\necho MQTT_PASS=hunter2\n")
consumer = _write_installed_app(
apps_dir(),
"zigbee2mqtt",
requires=[{"app": "mosquitto", "on_install": "hooks/create-user.sh"}],
)
# Consumer's .env starts empty.
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "zigbee2mqtt", ["mosquitto", "zigbee2mqtt"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
captured = {}
def fake_exec_script(app_dir, project, service, script_path, *, env, timeout):
captured["app_dir"] = app_dir
captured["project"] = project
captured["service"] = service
captured["script_path"] = script_path
captured["env"] = env
captured["timeout"] = timeout
return "MQTT_USER=z2m\nMQTT_PASS=hunter2\n"
# Tell the provider_exec_service helper to pick a deterministic service.
monkeypatch.setattr(
dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "eclipse-mosquitto:2"}
)
monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script)
runner.run_install("zigbee2mqtt")
# Hook was called against the provider, with the consumer's name + version
# in env, and the timeout we expect.
assert captured["project"] == "mosquitto"
assert captured["service"] == "mosquitto"
assert captured["script_path"] == hook
assert captured["env"] == {
"FURTKA_CONSUMER_APP": "zigbee2mqtt",
"FURTKA_CONSUMER_VERSION": "0.1.0",
}
assert captured["timeout"] == 60.0
# Consumer's .env now has the hook output.
env_text = (consumer / ".env").read_text()
assert "MQTT_USER=z2m" in env_text
assert "MQTT_PASS=hunter2" in env_text
# Mode 0600.
assert (consumer / ".env").stat().st_mode & 0o777 == 0o600
def test_run_install_hook_furtka_json_sentinel(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
# Hook output mixes plain KEY=VALUE and a FURTKA_JSON sentinel. JSON
# wins on conflict (overlays plain).
monkeypatch.setattr(
dockerops,
"compose_exec_script",
lambda *a, **k: 'MQTT_USER=oldval\nFURTKA_JSON: {"MQTT_USER": "newval", "TOKEN": "abc"}\n',
)
runner.run_install("z2m")
env_text = (consumer / ".env").read_text()
assert "MQTT_USER=newval" in env_text # JSON overlay wins
assert "TOKEN=abc" in env_text
def test_run_install_hook_rejects_bad_key_name(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: "lowercase_key=oops\n")
with pytest.raises(runner.InstallRunnerError, match="UPPER_SNAKE_CASE"):
runner.run_install("z2m")
s = runner.read_state()
assert s["stage"] == "error"
# Consumer's compose_up was never called because the hook failed.
assert not any(c[0] == "up" and c[1] == "z2m" for c in calls)
def test_run_install_hook_rejects_placeholder_value(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: "MQTT_PASS=changeme\n")
with pytest.raises(runner.InstallRunnerError, match="placeholder"):
runner.run_install("z2m")
def test_run_install_hook_failure_skips_consumer_compose_up(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/x.sh"}],
)
(apps_dir() / "mosquitto" / "hooks").mkdir()
(apps_dir() / "mosquitto" / "hooks" / "x.sh").write_bytes(b"")
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
def boom(*a, **k):
raise dockerops.DockerError("hook returned 1: connection refused")
monkeypatch.setattr(dockerops, "compose_exec_script", boom)
with pytest.raises(dockerops.DockerError):
runner.run_install("z2m")
s = runner.read_state()
assert s["stage"] == "error"
assert s["target"] == "z2m"
# The provider's compose_up DID run earlier in the plan.
assert ("up", "mosquitto") in calls
# But the consumer's never did.
assert ("up", "z2m") not in calls
def test_run_install_missing_provider_hook_file_raises(runner, monkeypatch):
import furtka.dockerops as dockerops
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "mosquitto")
consumer = _write_installed_app(
apps_dir(),
"z2m",
requires=[{"app": "mosquitto", "on_install": "hooks/missing.sh"}],
)
(consumer / ".env").write_text("")
_write_plan(runner.plan_path(), "z2m", ["mosquitto", "z2m"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
with pytest.raises(runner.InstallRunnerError, match="missing in provider"):
runner.run_install("z2m")
def test_run_install_plan_file_is_consumed_after_read(runner, monkeypatch):
"""After a run, the plan file is removed so a stale plan can't steer the next run."""
from furtka.paths import apps_dir
_write_installed_app(apps_dir(), "fileshare")
_write_plan(runner.plan_path(), "fileshare", ["fileshare"])
calls: list = []
_stub_docker_ops(monkeypatch, calls)
runner.run_install("fileshare")
assert not runner.plan_path().exists()
# --- _parse_hook_output (unit) -----------------------------------------------
def test_parse_hook_output_kv_only(runner):
out = runner._parse_hook_output("MQTT_USER=z2m\nMQTT_PASS=hunter2\n")
assert out == {"MQTT_USER": "z2m", "MQTT_PASS": "hunter2"}
def test_parse_hook_output_rejects_lowercase_key(runner):
with pytest.raises(runner.InstallRunnerError, match="UPPER_SNAKE_CASE"):
runner._parse_hook_output("lowercase=oops\n")
def test_parse_hook_output_furtka_json(runner):
out = runner._parse_hook_output('FURTKA_JSON: {"FOO": "bar", "BAZ": "qux"}\n')
assert out == {"FOO": "bar", "BAZ": "qux"}
def test_parse_hook_output_furtka_json_rejects_non_string(runner):
with pytest.raises(runner.InstallRunnerError, match="must be a string"):
runner._parse_hook_output('FURTKA_JSON: {"FOO": 42}\n')
def test_parse_hook_output_furtka_json_rejects_bad_payload(runner):
with pytest.raises(runner.InstallRunnerError, match="must be an object"):
runner._parse_hook_output('FURTKA_JSON: ["not", "a", "dict"]\n')
def test_parse_hook_output_furtka_json_invalid_json(runner):
with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"):
runner._parse_hook_output("FURTKA_JSON: {not json}\n")

View file

@ -73,6 +73,31 @@ def test_install_from_copies_files(tmp_path, fake_dirs):
assert (target / ".env").read_text() == "A=1" 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): def test_install_from_preserves_existing_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new") src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new")
target = apps_dir() / "fileshare" target = apps_dir() / "fileshare"
@ -355,3 +380,85 @@ def test_update_env_rejects_invalid_path(tmp_path, fake_dirs):
# Then try to update to a bad path. # Then try to update to a bad path.
with pytest.raises(installer.InstallError, match="does not exist"): with pytest.raises(installer.InstallError, match="does not exist"):
installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")}) installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")})
# --- parse_env_text ----------------------------------------------------------
def test_parse_env_text_basic():
from furtka.installer import parse_env_text
out = parse_env_text("A=1\nB=two\n#comment\n\nC=three=four\n")
assert out == {"A": "1", "B": "two", "C": "three=four"}
def test_parse_env_text_handles_quoted_values():
from furtka.installer import parse_env_text
out = parse_env_text('A="has space"\nB=\'plain\'\nC="quote \\"inside\\""\n')
assert out == {"A": "has space", "B": "plain", "C": 'quote "inside"'}
def test_parse_env_text_ignores_malformed_lines():
from furtka.installer import parse_env_text
out = parse_env_text("no-equals-sign\n=missing-key\nGOOD=ok\n")
assert out == {"GOOD": "ok", "": "missing-key"}
# --- install_plan driver -----------------------------------------------------
def test_install_plan_calls_install_from_in_order(tmp_path, fake_dirs, monkeypatch):
from furtka.deps import DepPlan
calls: list[tuple[str, dict | None]] = []
def fake_resolve(name):
return tmp_path / "src" / name
def fake_install_from(src, settings=None):
calls.append((src.name, settings))
return apps_dir() / src.name
monkeypatch.setattr(installer, "resolve_source", fake_resolve)
monkeypatch.setattr(installer, "install_from", fake_install_from)
plan = DepPlan(
target="a",
install_order=("c", "b", "a"),
already_installed=frozenset(),
to_install=("c", "b", "a"),
)
out = installer.install_plan(plan, settings_target={"K": "v"})
assert [name for name, _ in calls] == ["c", "b", "a"]
# Only the target receives settings.
assert calls[0] == ("c", None)
assert calls[1] == ("b", None)
assert calls[2] == ("a", {"K": "v"})
assert [p.name for p in out] == ["c", "b", "a"]
def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
from furtka.deps import DepPlan
calls: list[str] = []
def fake_resolve(name):
return tmp_path / "src" / name
def fake_install_from(src, settings=None):
calls.append(src.name)
return apps_dir() / src.name
monkeypatch.setattr(installer, "resolve_source", fake_resolve)
monkeypatch.setattr(installer, "install_from", fake_install_from)
plan = DepPlan(
target="a",
install_order=("b", "a"),
already_installed=frozenset({"b"}),
to_install=("a",),
)
installer.install_plan(plan)
assert calls == ["a"]

View file

@ -191,3 +191,104 @@ def test_settings_non_list_rejected(tmp_path):
path = _write_app(tmp_path, "fileshare", bad) path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="settings must be a list"): with pytest.raises(ManifestError, match="settings must be a list"):
load_manifest(path) load_manifest(path)
def test_requires_optional_default_empty(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.requires == ()
def test_requires_parsed_full_entry(tmp_path):
payload = dict(
VALID_MANIFEST,
name="zigbee2mqtt",
requires=[
{
"app": "mosquitto",
"on_install": "hooks/create-user.sh",
"on_start": "hooks/ensure-user.sh",
}
],
)
path = _write_app(tmp_path, "zigbee2mqtt", payload)
m = load_manifest(path)
assert len(m.requires) == 1
r = m.requires[0]
assert r.app == "mosquitto"
assert r.on_install == "hooks/create-user.sh"
assert r.on_start == "hooks/ensure-user.sh"
def test_requires_app_only_no_hooks(tmp_path):
payload = dict(VALID_MANIFEST, name="z2m", requires=[{"app": "mosquitto"}])
path = _write_app(tmp_path, "z2m", payload)
m = load_manifest(path)
assert m.requires[0].app == "mosquitto"
assert m.requires[0].on_install is None
assert m.requires[0].on_start is None
def test_requires_rejects_self_reference(tmp_path):
payload = dict(VALID_MANIFEST, requires=[{"app": "fileshare"}])
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="self-reference"):
load_manifest(path)
def test_requires_rejects_duplicate_app(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto"}, {"app": "mosquitto"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="duplicate"):
load_manifest(path)
def test_requires_rejects_traversal_hook_path(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_install": "../../etc/passwd"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match=r"must not contain '\.\.'"):
load_manifest(path)
def test_requires_rejects_absolute_hook_path(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_start": "/tmp/hook.sh"}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="must be relative"):
load_manifest(path)
def test_requires_non_list_rejected(tmp_path):
payload = dict(VALID_MANIFEST, requires={"app": "mosquitto"})
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="requires must be a list"):
load_manifest(path)
def test_requires_rejects_invalid_app_name(tmp_path):
payload = dict(VALID_MANIFEST, requires=[{"app": "Bad-Name!"}])
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="lowercase app name"):
load_manifest(path)
def test_requires_rejects_empty_hook_string(tmp_path):
payload = dict(
VALID_MANIFEST,
name="z2m",
requires=[{"app": "mosquitto", "on_install": ""}],
)
path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="non-empty string"):
load_manifest(path)

View file

@ -133,3 +133,149 @@ def test_reconcile_isolates_missing_docker_binary(tmp_path, monkeypatch):
error = next(a for a in actions if a.kind == "error") error = next(a for a in actions if a.kind == "error")
assert error.target == "fileshare" assert error.target == "fileshare"
assert "docker" in error.detail assert "docker" in error.detail
# --- Topo ordering + on_start hooks ----------------------------------------
PROVIDER_MANIFEST = dict(
VALID_MANIFEST,
name="mosquitto",
volumes=["data"],
)
CONSUMER_MANIFEST = dict(
VALID_MANIFEST,
name="zigbee2mqtt",
volumes=["state"],
requires=[
{
"app": "mosquitto",
"on_start": "hooks/ensure-user.sh",
}
],
)
def test_reconcile_topo_orders_providers_before_consumers(tmp_path, fake_docker, monkeypatch):
# Consumer comes alphabetically AFTER provider here, but the explicit dep
# also needs to win when the order was reversed. Add an alpha-first
# consumer name to make this load-bearing.
consumer = dict(CONSUMER_MANIFEST, name="alpha", requires=[{"app": "mosquitto"}])
_make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
_make_app(tmp_path, "alpha", consumer)
reconciler.reconcile(tmp_path)
up_order = [project for _, project in fake_docker["compose_up"]]
assert up_order == ["mosquitto", "alpha"]
def test_reconcile_fires_on_start_before_compose_up(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"#!/bin/sh\necho ok\n")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
hook_calls: list[str] = []
def fake_exec_script(app_dir, project, service, script_path, *, env, timeout):
hook_calls.append(f"{project}:{script_path.name}")
return ""
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script)
actions = reconciler.reconcile(tmp_path)
# Hook fired against mosquitto exactly once.
assert hook_calls == ["mosquitto:ensure-user.sh"]
# Hook action appears before consumer's compose_up.
kinds = [(a.kind, a.target) for a in actions]
hook_idx = kinds.index(("hook", "zigbee2mqtt:mosquitto:on_start"))
up_idx = kinds.index(("compose_up", "zigbee2mqtt"))
assert hook_idx < up_idx
# And the provider's compose_up happened first.
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()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
# Unrelated third app: must still come up despite the consumer's hook fail.
_make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"]))
def boom(*a, **k):
raise dockerops.DockerError("hook returned 1")
monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"})
monkeypatch.setattr(dockerops, "compose_exec_script", boom)
actions = reconciler.reconcile(tmp_path)
assert reconciler.has_errors(actions)
error_actions = [a for a in actions if a.kind == "error"]
assert len(error_actions) == 1
assert error_actions[0].target == "zigbee2mqtt"
assert "on_start(mosquitto)" in error_actions[0].detail
# Provider AND unrelated app came up; consumer did NOT.
up_projects = {p for _, p in fake_docker["compose_up"]}
assert "mosquitto" in up_projects
assert "lonely" in up_projects
assert "zigbee2mqtt" not in up_projects
def test_reconcile_dry_run_emits_hook_action_without_executing(tmp_path, fake_docker, monkeypatch):
provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST)
(provider / "hooks").mkdir()
(provider / "hooks" / "ensure-user.sh").write_bytes(b"")
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
called = []
monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: called.append(1) or "")
actions = reconciler.reconcile(tmp_path, dry_run=True)
assert called == []
hook_actions = [a for a in actions if a.kind == "hook"]
assert any(a.target == "zigbee2mqtt:mosquitto:on_start" for a in hook_actions)
def test_reconcile_missing_provider_still_isolated(tmp_path, fake_docker, monkeypatch):
"""Consumer requires an app that isn't installed — per-app error, others continue."""
_make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST)
_make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"]))
actions = reconciler.reconcile(tmp_path)
assert reconciler.has_errors(actions)
errors = [a for a in actions if a.kind == "error"]
assert len(errors) == 1
assert errors[0].target == "zigbee2mqtt"
# `lonely` still got its compose_up.
assert any(p == "lonely" for _, p in fake_docker["compose_up"])

View file

@ -316,14 +316,17 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
linked = updater._link_new_units(unit_dir) linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"] assert linked == ["furtka-bar.timer"]
# Two calls for the newly-linked timer: systemctl link + systemctl enable. # Three calls for the newly-linked timer: link + enable + start. The
# The already-linked service is untouched. Timers need the follow-up # already-linked service is untouched. `enable` alone only arms the
# `enable` so self-updates that introduce new timers don't leave them # timer for the next boot; `start` makes it fire on the running box
# dormant — fresh installs get their enable via the webinstaller. # (26.20 left furtka-welcome.timer dead until reboot without it).
assert len(seen) == 2 assert len(seen) == 3
assert seen[0][:2] == ["systemctl", "link"] assert seen[0][:2] == ["systemctl", "link"]
assert seen[0][2].endswith("furtka-bar.timer") # Linked via the `current` symlink, not the versioned dir being scanned,
# so the link survives the next symlink flip / rollback.
assert seen[0][2] == str(updater.current_symlink() / "assets" / "systemd" / "furtka-bar.timer")
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"] assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
assert seen[2] == ["systemctl", "start", "furtka-bar.timer"]
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch): def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):

View file

@ -227,6 +227,25 @@ def test_systemd_units_reference_current_paths():
) )
def test_welcome_banner_is_timer_driven():
"""The console banner must keep refreshing so the IP fallback is always
on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer
(the unit stays 'active'), and a network-online dependency would delay the
first paint both regressions the hardware bench hit in 26.19."""
timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text()
assert "OnUnitActiveSec=" in timer
assert "furtka-welcome.timer" in app._FURTKA_UNITS
service = (ASSETS / "systemd" / "furtka-welcome.service").read_text()
assert "RemainAfterExit" not in service
assert "network-online.target" not in service
def test_welcome_banner_always_prints_fallback_line():
script = (ASSETS / "bin" / "furtka-welcome").read_text()
assert "no IP address yet" in script
assert "fallback" in script
def test_read_asset_raises_for_missing_file(): def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html") app._read_asset("does/not/exist.html")
@ -259,3 +278,12 @@ def test_post_install_writes_users_json_with_hashed_password(install_cmds):
# Hash is a real werkzeug hash, not the plaintext password. # Hash is a real werkzeug hash, not the plaintext password.
assert parsed["admin"]["hash"] != "test-admin-pw" assert parsed["admin"]["hash"] != "test-admin-pw"
assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw") assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
def test_catalog_sync_service_retries_on_failure():
"""First sync at boot can race DHCPv4 (IPv6 makes network-online fire
early); the unit must retry rather than wait for the daily timer."""
body = (ASSETS / "systemd" / "furtka-catalog-sync.service").read_text()
assert "Restart=on-failure" in body
assert "RestartSec=" in body
assert "StartLimitBurst=" in body

View file

@ -11,6 +11,7 @@ import sys
from datetime import UTC from datetime import UTC
from pathlib import Path from pathlib import Path
from diskprep import release_disk
from drives import list_scored_devices from drives import list_scored_devices
from flask import Flask, jsonify, redirect, render_template, request, url_for from flask import Flask, jsonify, redirect, render_template, request, url_for
@ -96,6 +97,16 @@ PROGRESS_PHASES = [
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:") PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
# Known failure signatures → a phase label that tells the user what to do
# instead of a bare "open Show details". Checked in order; first hit wins.
PROGRESS_ERROR_HINTS = [
(
"unable to inform the kernel of the change",
"Installation failed — the disk was still in use (old LVM/RAID/swap "
"from a previous system). Reboot the installer and try again",
),
]
def parse_install_progress(log): def parse_install_progress(log):
percent = 2 percent = 2
@ -110,6 +121,10 @@ def parse_install_progress(log):
elif any(m in log for m in PROGRESS_ERROR_MARKERS): elif any(m in log for m in PROGRESS_ERROR_MARKERS):
status = "error" status = "error"
phase = "Installation failed — open Show details below" phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS:
if marker in log:
phase = hint
break
else: else:
status = "running" status = "running"
@ -264,6 +279,9 @@ _FURTKA_UNITS = (
"furtka-status.service", "furtka-status.service",
"furtka-status.timer", "furtka-status.timer",
"furtka-welcome.service", "furtka-welcome.service",
# Re-runs the welcome banner every few seconds so the console always
# shows the current IP fallback (or a "no IP yet" hint).
"furtka-welcome.timer",
# Daily apps-catalog pull. Timer drives the service; the .service itself # Daily apps-catalog pull. Timer drives the service; the .service itself
# is oneshot and also callable ad-hoc via `furtka catalog sync`. # is oneshot and also callable ad-hoc via `furtka catalog sync`.
"furtka-catalog-sync.service", "furtka-catalog-sync.service",
@ -478,7 +496,7 @@ def build_archinstall_config(s):
"docker", "docker",
# Base OS post-install services. Only packaged units go here — # Base OS post-install services. Only packaged units go here —
# archinstall runs `systemctl enable` on this list *before* # archinstall runs `systemctl enable` on this list *before*
# custom_commands, so our own furtka-welcome + furtka-status.timer # custom_commands, so our own furtka-welcome.timer + furtka-status.timer
# units (written in custom_commands) are enabled there instead. # units (written in custom_commands) are enabled there instead.
"caddy", "caddy",
"avahi-daemon", "avahi-daemon",
@ -534,7 +552,9 @@ def write_install_files(s, state_dir):
def spawn_archinstall(config_path, creds_path, log_path): def spawn_archinstall(config_path, creds_path, log_path):
log_fh = open(log_path, "wb") # Append: install_run() already truncated the log and may have written
# the disk-release lines we want to keep visible above archinstall's own.
log_fh = open(log_path, "ab")
return subprocess.Popen( return subprocess.Popen(
[ [
"archinstall", "archinstall",
@ -606,6 +626,11 @@ def install_run():
f"--creds {creds_path} --silent\n" f"--creds {creds_path} --silent\n"
) )
else: else:
# Free the target disk first: udev auto-activates old LVM/RAID/swap
# on the live ISO, and archinstall can't re-read a partition table
# while those hold the partitions open (first hardware bench, 26.19).
with INSTALL_LOG.open("a") as fh:
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
spawn_archinstall(config_path, creds_path, INSTALL_LOG) spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
@ -617,6 +642,7 @@ def install_log_view():
"install/log.html", "install/log.html",
log=log, log=log,
progress=parse_install_progress(log), progress=parse_install_progress(log),
hostname=settings["hostname"],
) )

125
webinstaller/diskprep.py Normal file
View file

@ -0,0 +1,125 @@
"""Release a target disk before handing it to archinstall.
The live ISO ships lvm2, mdadm, dmraid and cryptsetup (releng defaults), and
udev happily auto-activates whatever it finds on attached disks at boot. A
drive that used to live in another machine the first hardware bench had an
old Proxmox VE install on its SSD therefore comes up with device-mapper
volumes (or swap, or md arrays) stacked on top of its partitions. archinstall
then writes the new partition table fine but `BLKRRPART` fails with
Partition(s) 2, 3 on /dev/sda have been written, but we have been unable
to inform the kernel of the change, probably because it/they are in use.
and the install dies at 12 %. Nothing on a fresh VM ever triggers this,
which is why it survived every smoke run.
`release_disk()` tears that stack down leaves-first (swapoff, umount, dmsetup
remove / mdadm --stop), wipes signatures and asks the kernel to re-read the
partition table. Every step is logged so the install log shows what was
done. All commands are best-effort: the point is to leave archinstall a
disk the kernel can re-partition, not to be a full disk-management tool.
"""
import re
import subprocess
_PAIR_RE = re.compile(r'(\w+)="([^"]*)"')
def _run(cmd, run):
try:
result = run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return 127, f"{cmd[0]}: command not found"
out = ((result.stdout or "") + (result.stderr or "")).strip()
return result.returncode, out
def _parse_pairs(output):
"""Parse `lsblk -P` key="value" lines into dicts."""
rows = []
for line in output.splitlines():
row = {}
for match in _PAIR_RE.finditer(line):
row[match.group(1)] = match.group(2)
if row.get("NAME"):
rows.append(row)
return rows
def _children(disk, run):
"""Everything stacked on `disk`, in lsblk tree order, disk itself excluded."""
rc, out = _run(["lsblk", "-lnP", "-o", "NAME,TYPE,MOUNTPOINTS", disk], run)
if rc != 0:
return []
rows = _parse_pairs(out)
seen = set()
children = []
for row in rows:
if row.get("TYPE") == "disk" or row["NAME"] in seen:
continue
seen.add(row["NAME"])
children.append(row)
return children
def release_disk(disk, run=subprocess.run):
"""Free `disk` (e.g. "/dev/sda") of anything that would block a
partition-table reload. Returns the log lines describing what happened."""
if not disk.startswith("/dev/"):
disk = f"/dev/{disk}"
lines = [f"Releasing {disk} before install…"]
children = _children(disk, run)
if not children:
lines.append(" nothing stacked on the disk — good.")
else:
lines.append(" found: " + ", ".join(f"{c['NAME']} ({c['TYPE']})" for c in children))
# Leaves first: mountpoints and swap sit on the outermost devices.
for child in reversed(children):
dev = f"/dev/{child['NAME']}"
mounts = child.get("MOUNTPOINTS", "")
if "[SWAP]" in mounts:
rc, out = _run(["swapoff", dev], run)
lines.append(f" swapoff {dev}: {'ok' if rc == 0 else out}")
elif mounts:
rc, out = _run(["umount", "--all-targets", dev], run)
lines.append(f" umount {dev}: {'ok' if rc == 0 else out}")
# Device-mapper (LVM, thin pools, LUKS) and md stacks. Thin pools need
# their thin LVs gone before the pool goes, and lsblk lists shared nodes
# more than once, so go leaves-first and retry a couple of passes until
# nothing dm/md-shaped is left.
for _ in range(3):
stacked = [c for c in reversed(_children(disk, run)) if c["TYPE"] != "part"]
if not stacked:
break
for child in stacked:
name, typ = child["NAME"], child["TYPE"]
if typ.startswith("raid") or name.startswith("md"):
cmd = ["mdadm", "--stop", f"/dev/{name}"]
else:
cmd = ["dmsetup", "remove", name]
rc, out = _run(cmd, run)
lines.append(f" {' '.join(cmd)}: {'ok' if rc == 0 else out}")
leftovers = [c["NAME"] for c in _children(disk, run) if c["TYPE"] != "part"]
if leftovers:
lines.append(
" WARNING: still in use after cleanup: "
+ ", ".join(leftovers)
+ " — the install may fail to re-read the partition table."
)
# Old signatures (LVM PV headers, RAID superblocks) would otherwise be
# re-activated by udev the moment the kernel re-reads the table.
for child in reversed(_children(disk, run)):
if child["TYPE"] == "part":
rc, out = _run(["wipefs", "-a", f"/dev/{child['NAME']}"], run)
lines.append(f" wipefs /dev/{child['NAME']}: {'ok' if rc == 0 else out}")
rc, out = _run(["wipefs", "-a", disk], run)
lines.append(f" wipefs {disk}: {'ok' if rc == 0 else out}")
rc, out = _run(["blockdev", "--rereadpt", disk], run)
lines.append(f" re-read partition table: {'ok' if rc == 0 else out}")
return lines

View file

@ -421,3 +421,11 @@ select:focus {
outline-offset: 3px; outline-offset: 3px;
border-radius: 2px; border-radius: 2px;
} }
/* "Furtka is ready" — the two steps before the restart button (log.html). */
.next-steps {
margin: 1.5rem 0 1rem;
padding-left: 1.4rem;
line-height: 1.5;
}
.next-steps li { margin-bottom: 0.6rem; }

View file

@ -17,16 +17,25 @@
{% else %}Installing Furtka{% endif %} {% else %}Installing Furtka{% endif %}
</h1> </h1>
<p class="lede" id="install-lede"> <p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot. {% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help. {% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %} {% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
</p> </p>
{% if progress.status == "done" %} {% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the
installer would just come back. Seen on the first hardware bench. #}
<ol class="next-steps">
<li><strong>Pull out the USB stick</strong> (or eject the installer ISO in your VM).
If it stays in, the computer will start this installer again instead of Furtka.</li>
<li>Click the button below. The computer restarts and comes back as
<strong>http://{{ hostname }}.local</strong> in about a minute.</li>
</ol>
<form method="post" action="{{ url_for('install_reboot') }}" <form method="post" action="{{ url_for('install_reboot') }}"
onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');"> onsubmit="return confirm('USB stick removed? Click OK to restart the computer.');">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Reboot now</button> <button type="submit" class="btn btn-primary">USB stick is out — restart now</button>
</div> </div>
</form> </form>
{% endif %} {% endif %}

View file

@ -42,6 +42,15 @@ This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path, `hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
just with an SSH hop. just with an SSH hop.
### Poll counter
`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`,
SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or
update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location
and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told
apart by a salted hash of IP + user agent — no cookie — and a repeat vote
from the same device changes the choice instead of adding one.
### First-time VM setup ### First-time VM setup
Only needed once, when provisioning a fresh forge-runner VM: Only needed once, when provisioning a fresh forge-runner VM:
@ -58,11 +67,18 @@ hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages content/ Markdown pages
_index.md Home (EN) _index.md Home (EN)
_index.de.md Home (DE) _index.de.md Home (DE)
logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/
layouts/ Custom inline theme — no external theme or framework layouts/ Custom inline theme — no external theme or framework
_default/ baseof, single, list _default/ baseof, single, list
partials/ head, header, footer, gate SVG, lang switcher partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
poll/single.html Logo poll page; strings come from the content front matter
index.html Home-only layout with editorial hero index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build) assets/css/main.css Stylesheet (fingerprinted + minified on build)
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
data/logo/{m,r}.json Generated: mark SVGs, lockups, 16 px icons, console banner per proposal
static/img/logo/ Generated: tiles, splash and social card per proposal
tools/logo-assets.py Generator for the two lines above (cairosvg + Pillow + fontTools);
run on the dev box, commit the output. Font: tools/FONT-PROVENANCE.md
static/favicon.svg Gate mark in crimson static/favicon.svg Gate mark in crimson
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes) deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI

View file

@ -439,3 +439,132 @@ main.container {
will-change: auto; will-change: auto;
} }
} }
/* ── Logo poll ───────────────────────────────────────────────── */
.poll-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: 1.5rem;
margin-top: 2.5rem;
}
.poll-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
transition: border-color 160ms, box-shadow 160ms;
}
.is-yours { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; }
.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; }
.poll-art { display: grid; place-items: center; padding: 2.2rem 1rem; border-radius: 0.75rem; }
.poll-art--m { background: var(--bg-subtle); }
.poll-art--r { background: #151515; }
.poll-art .pm { height: 9rem; width: auto; }
.pm { display: block; }
.poll-btn {
margin-top: auto;
padding: 0.85rem 1.2rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 140ms, transform 140ms;
}
.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); }
.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.poll-btn:disabled { cursor: default; opacity: 0.85; }
.is-yours .poll-btn { background: transparent; color: var(--accent); }
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; }
.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
.poll--voted .poll-bar { display: block; }
.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
.poll-status { margin: 1.25rem 0 0; min-height: 1.5em; color: var(--fg-muted); }
.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }
.poll-note a { color: var(--accent); }
/* showcase */
.show { display: flex; flex-direction: column; gap: 3rem; margin-top: 4rem; }
.show-intro { margin: 0; }
.show-row { display: flex; flex-direction: column; gap: 0.6rem; }
.show-h { margin: 0; font-size: 1.35rem; letter-spacing: -0.01em; }
.show-p { margin: 0 0 0.6rem; max-width: var(--measure); color: var(--fg-muted); font-size: 0.95rem; }
.show-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.25rem; }
.show-cell {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.show-cell--m { color: var(--accent); }
.show-cell--r { color: #af6428; }
.show-tag { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
.sizes { display: flex; align-items: flex-end; gap: 1.1rem; flex-wrap: wrap; }
.sizes figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.sizes figcaption { font-family: var(--font-mono); font-size: 0.65rem; color: var(--fg-muted); }
.sz { display: block; }
.sz .pm { width: auto; }
.sz-96 .pm { height: 96px; } .sz-48 .pm { height: 48px; } .sz-32 .pm { height: 32px; } .sz-24 .pm { height: 24px; }
.px { image-rendering: pixelated; }
.lock { display: grid; place-items: center; padding: 1rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.lock-h .pm { height: 3rem; width: auto; max-width: 100%; }
.lock-s .pm { height: 7rem; width: auto; max-width: 100%; }
.mock { border: 1px solid var(--border); border-radius: 0.7rem; overflow: hidden; background: var(--bg); color: var(--fg); font-size: 0.8rem; }
.tabbar { background: var(--bg-subtle); padding: 0.55rem 0.65rem; }
.tab { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--bg); color: var(--fg-muted); }
.urlbar { padding: 0.45rem 0.75rem; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); }
.uihead { display: flex; align-items: center; gap: 0.5rem; padding: 0.8rem 0.9rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.uihead-mark { display: inline-flex; color: inherit; }
.show-cell--m .uihead-mark { color: var(--accent); }
.show-cell--r .uihead-mark { color: #af6428; }
.uihead-mark .pm { height: 1.25rem; width: auto; }
.uihead-name { font-weight: 600; letter-spacing: 0.13em; text-transform: uppercase; font-size: 0.72rem; color: var(--fg); }
.uihead-nav { margin-left: auto; display: flex; gap: 0.7rem; color: var(--fg-muted); }
.uibody { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; }
.uibody i { display: block; height: 0.45rem; border-radius: 3px; background: var(--bg-subtle); }
.uibody i.s { width: 45%; }
.tiles { display: flex; gap: 1rem; justify-content: center; padding: 1.2rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.tiles img { width: 6rem; height: 6rem; }
.term {
margin: 0; padding: 0.9rem 1rem; border-radius: 0.6rem; overflow-x: auto;
background: #100f0c; color: #d8d2c2; font-family: var(--font-mono); font-size: 0.55rem; line-height: 1;
-webkit-text-size-adjust: none; text-size-adjust: none;
}
.term i, .term b { font-style: normal; font-weight: 400; color: inherit; }
.show-cell--m .term i { color: #ff6b56; } .show-cell--m .term b { color: #ff6b56; }
.show-cell--r .term i { color: #c9803f; } .show-cell--r .term b { color: #c9803f; }
.shot { display: block; width: 100%; height: auto; border-radius: 0.6rem; }
.grounds { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
.ground { display: grid; place-items: center; padding: 1.2rem; border-radius: 0.6rem; border: 1px solid var(--border); }
.ground--fg { background: var(--bg-subtle); color: var(--fg); }
.ground .pm { height: 3.2rem; width: auto; }
.poll-final { margin-top: 3.5rem; display: flex; flex-direction: column; gap: 1rem; }
.poll-final-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
.poll-final-card {
display: flex; flex-direction: column; gap: 0.8rem; align-items: stretch;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.poll-final-mark { display: grid; place-items: center; padding: 0.8rem; }
.poll-final-mark .pm { height: 3.5rem; width: auto; }
.home-poll {
display: inline-flex; align-items: center; gap: 0.6rem; margin-top: 1.4rem;
padding: 0.55rem 0.95rem; border: 1px solid var(--border); border-radius: 999px;
background: var(--card-bg); color: var(--fg); text-decoration: none; font-size: 0.92rem;
}
.home-poll:hover { border-color: var(--accent); }
.home-poll .pm { height: 1.1rem; width: auto; color: var(--accent); }
.home-poll .pm + .pm { color: #af6428; }
/* Random left/right per visit data-logo-order is set in head.html before first paint.
Every pair container is a grid/flex, so `order` swaps the columns without touching the DOM. */
[data-logo-order="rm"] .poll-card[data-choice="m"],
[data-logo-order="rm"] .show-cell.show-cell--m,
[data-logo-order="rm"] .poll-final-card[data-choice="m"],
[data-logo-order="mr"] .poll-card[data-choice="r"],
[data-logo-order="mr"] .show-cell.show-cell--r,
[data-logo-order="mr"] .poll-final-card[data-choice="r"] { order: 1; }
@media (prefers-reduced-motion: reduce) { .poll-bar i, .poll-btn { transition: none; } }

59
website/assets/js/poll.js Normal file
View file

@ -0,0 +1,59 @@
// furtka.org logo poll — no cookies, no storage. The server tells us
// what this device already chose (salted hash of IP + user agent).
(function () {
var root = document.querySelector('.poll');
if (!root || !window.fetch) return;
var api = root.dataset.api;
var status = root.querySelector('.poll-status');
var t = status.dataset;
var buttons = root.querySelectorAll('[data-vote]');
var busy = false;
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
function each(sel, fn) { Array.prototype.forEach.call(root.querySelectorAll(sel), fn); }
function render(d) {
var total = d.total || 0;
root.classList.toggle('poll--voted', !!d.yours);
Object.keys(d.counts).forEach(function (k) {
var n = d.counts[k], p = pct(n, total), mine = d.yours === k;
each('[data-choice="' + k + '"]', function (card) {
card.classList.toggle('is-yours', mine);
var bar = card.querySelector('.poll-bar i'); if (bar) bar.style.width = p + '%';
var c = card.querySelector('[data-count]'); if (c) c.textContent = d.yours ? p + ' % · ' + n : '';
});
each('[data-vote="' + k + '"]', function (b) {
b.disabled = mine;
b.textContent = mine ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
});
});
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
}
function load() {
fetch(api, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(render)
.catch(function () { status.textContent = t.tError; });
}
Array.prototype.forEach.call(buttons, function (b) {
b.dataset.label = b.textContent;
b.addEventListener('click', function () {
if (busy) return;
busy = true; b.disabled = true;
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ choice: b.dataset.vote })
})
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(function (d) { render(d); status.scrollIntoView({ block: 'nearest' }); })
.catch(function () { status.textContent = t.tError; b.disabled = false; })
.then(function () { busy = false; });
});
});
status.textContent = '';
load();
})();

View file

@ -1,7 +1,9 @@
--- ---
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.20-alpha</span> — in Arbeit"
poll_link: "/de/logo/"
poll_text: "Wir suchen ein Logo — hilf uns beim Aussuchen"
# 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.
@ -19,11 +21,15 @@ features_today:
- "Apps mit einem Klick installieren und entfernen" - "Apps mit einem Klick installieren und entfernen"
- "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)" - "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)"
- "Furtka selbst mit einem Klick aktualisieren — keine Neuinstallation mehr für neue Features" - "Furtka selbst mit einem Klick aktualisieren — keine Neuinstallation mehr für neue Features"
- "Die Bedienseite fragt nach einem Passwort — niemand im WLAN kommt einfach so rein"
- "Neue Apps kommen aus einem Online-Katalog, ohne auf ein Furtka-Update zu warten"
- "Apps können auf anderen Apps aufbauen — was eine braucht, wird mit installiert"
- "Verschlüsselte Verbindung im Heimnetz, in den Einstellungen einschaltbar"
features_next_label: "Was als Nächstes kommt" features_next_label: "Was als Nächstes kommt"
features_next: features_next:
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien" - "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
- "Einfachere Sprache im Einrichtungs-Assistenten" - "Einfachere Sprache im Einrichtungs-Assistenten"
- "Sichere Verbindung im Heimnetz (ohne Warnmeldung im Browser)" - "Den Server auch von unterwegs erreichen"
- "Mehrere Server zusammenschalten" - "Mehrere Server zusammenschalten"
--- ---

View file

@ -1,7 +1,9 @@
--- ---
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.20-alpha</span> — work in progress"
poll_link: "/logo/"
poll_text: "We're picking a logo — help us choose"
# 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.
@ -19,11 +21,15 @@ features_today:
- "Install and remove apps with one click" - "Install and remove apps with one click"
- "Update an installed app with one click (pulls the newest container image)" - "Update an installed app with one click (pulls the newest container image)"
- "Update Furtka itself with one click — no reinstalling for new features" - "Update Furtka itself with one click — no reinstalling for new features"
- "The control page asks for a password — nobody on the Wi-Fi can just walk in"
- "New apps arrive from an online catalog, without waiting for a Furtka update"
- "Apps can build on other apps — what one needs gets installed alongside it"
- "Encrypted connection on your home network, switched on in settings"
features_next_label: "What's coming next" features_next_label: "What's coming next"
features_next: features_next:
- "Apps for photos, files, smart home, game streaming and media" - "Apps for photos, files, smart home, game streaming and media"
- "Plainer language in the setup wizard" - "Plainer language in the setup wizard"
- "Secure connection on your home network (no browser warning)" - "Reaching your server from outside the house"
- "Linking several servers together" - "Linking several servers together"
--- ---

View file

@ -11,7 +11,8 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
Skripte von Drittanbietern**, bindet **keine Analyse- oder Skripte von Drittanbietern**, bindet **keine Analyse- oder
Tracking-Dienste** ein und enthält **keine externen Einbettungen** Tracking-Dienste** ein und enthält **keine externen Einbettungen**
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind (YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
ausschließlich kurzfristige Server-Zugriffsprotokolle. ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
### Verantwortlicher ### Verantwortlicher
@ -43,6 +44,22 @@ an Betrieb und Sicherheit).
Infrastruktur; es gibt keinen externen Auftragsverarbeiter. Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
**Drittlandübermittlung:** keine. **Drittlandübermittlung:** keine.
### Logo-Abstimmung
Auf der Seite [/de/logo/](/de/logo/) können Sie zwischen zwei
Logo-Vorschlägen abstimmen. Damit jedes Gerät ohne Cookie einmal gezählt
wird, speichert der Abstimmungsserver einen **gesalzenen SHA-256-Hash aus
Ihrer IP-Adresse und Browser-Kennung** zusammen mit Ihrer Wahl und dem
Zeitpunkt der Stimmabgabe. Der Hash lässt sich nicht in die Adresse
zurückrechnen; das Salt verlässt den Server nicht.
**Zweck:** Zählung je Gerät einmal.
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse
an einer ehrlichen Auszählung; die Teilnahme ist freiwillig.
**Speicherdauer:** bis zum Ende der Abstimmung, danach wird die Datenbank
gelöscht.
**Empfänger:** keine.
### Cookies und Tracking ### Cookies und Tracking
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
@ -75,5 +92,5 @@ Website: <https://www.datenschutz.rlp.de>
### Stand ### Stand
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026 Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
aktualisiert. aktualisiert.

View file

@ -11,7 +11,8 @@ sitemap:
This website sets **no cookies**, loads **no third-party fonts or This website sets **no cookies**, loads **no third-party fonts or
scripts**, embeds **no analytics or tracking services**, and contains scripts**, embeds **no analytics or tracking services**, and contains
**no external embeds** (YouTube, Maps, social buttons, …). The only **no external embeds** (YouTube, Maps, social buttons, …). The only
technical data collected is short-lived server access logs. technical data collected is short-lived server access logs — plus, while
the [logo poll](/logo/) is running, one salted hash per vote (see below).
### Controller ### Controller
@ -43,6 +44,20 @@ and security.
external processor is involved. external processor is involved.
**Transfers outside the EU/EEA:** none. **Transfers outside the EU/EEA:** none.
### Logo poll
The page [/logo/](/logo/) lets you vote between two logo proposals. So
that each device is counted once without a cookie, the poll server stores
a **salted SHA-256 hash of your IP address and browser identification**
together with your choice and the time of the vote. The hash cannot be
turned back into the address; the salt never leaves the server.
**Purpose:** counting votes once per device.
**Legal basis:** Art. 6(1)(f) GDPR — legitimate interest in an honest
tally; voting is voluntary.
**Retention:** until the poll is closed, then the database is deleted.
**Recipients:** none.
### Cookies and tracking ### Cookies and tracking
None. No cookies are set, no localStorage or sessionStorage is used, and None. No cookies are set, no localStorage or sessionStorage is used, and
@ -75,6 +90,6 @@ Website: <https://www.datenschutz.rlp.de>
### Last updated ### Last updated
This statement was last updated on 2026-04-18. This statement was last updated on 2026-08-26.
The German version of this privacy statement is the legally binding one. The German version of this privacy statement is the legally binding one.

View file

@ -0,0 +1,53 @@
---
title: "Welches Logo?"
description: "Zwei Vorschläge für das Furtka-Zeichen. Wähl das, das dir besser gefällt — ein Klick genügt."
translationKey: "logo"
layout: single
type: poll
poll: logo
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "M"
title: "M — das f im Tor"
text: "Ein kleines f, aus einem Rundbogentor ausgestanzt. Der Stamm ist der Torpfosten, die zwei Balken sind die Querriegel."
r:
short: "R"
title: "R — Fenster, Rad und F"
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
show:
intro: "Noch unentschieden? Hier ist jedes Zeichen überall dort, wo es funktionieren muss."
sizes: "Groß und klein"
sizes_text: "Vom Poster bis zum 16-Pixel-Symbol im Browser-Tab. Unter 32 Pixeln wechseln beide auf eine vereinfachte Zeichnung — M verliert nichts, R behält Fenster, Kreuz und Nabe."
name: "Mit dem Namen"
name_text: "Nebeneinander und gestapelt. Die Wortmarke ist bei beiden dieselbe, also entscheidet nur das Zeichen."
browser: "Im Browser"
browser_text: "Das Tab-Symbol in echter Größe und die Kopfzeile von furtka.org."
nav1: "Doku"
nav2: "Blog"
phone: "Auf dem Handy"
phone_text: "Startbildschirm-Symbol — eckig wie auf Android, rund wie manche Launcher es beschneiden."
box: "Auf dem Server"
box_text: "Der Text-Willkommensbildschirm, den der Server am Monitor zeigt, und der Startbildschirm beim Booten."
shared: "Geteilt"
shared_text: "Die Vorschaukarte, die erscheint, wenn jemand einen Link zu furtka.org postet."
colour: "In Farbe"
colour_text: "Auf hell, auf dunkel, auf der eigenen Farbe und in normaler Textfarbe — jedes Zeichen ist eine Form und nimmt die Farbe an, die es umgibt."
final: "Genug gesehen?"
labels:
vote: "Dieses hier"
voted: "Deine Wahl"
change: "Zu diesem wechseln"
total: "Bisher {n} Stimmen. Danke!"
error: "Die Abstimmung ist gerade nicht erreichbar — bitte gleich noch einmal versuchen."
nojs: "Zum Abstimmen wird JavaScript benötigt."
note: "Keine Cookies, kein Konto. Damit jedes Gerät einmal zählt, speichert der Server einen gesalzenen Hash aus IP-Adresse und Browser — nicht die Adresse selbst. Details in der <a href=\"/de/datenschutz/\">Datenschutzerklärung</a>."
---
*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch
die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen
damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs
Tor auf ein Rad. Stimm direkt hier ab — oder scroll erst weiter und sieh
beide Zeichen überall, wo sie funktionieren müssen, vom Poster bis zum
winzigen Symbol im Browser-Tab.

53
website/content/logo.md Normal file
View file

@ -0,0 +1,53 @@
---
title: "Which logo?"
description: "Two proposals for the Furtka mark. Pick the one you like better — it takes one click."
translationKey: "logo"
url: /logo/
layout: single
type: poll
poll: logo
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "M"
title: "M — the f in the gate"
text: "A lowercase f cut out of an arched gate. The stem is the gate post, the two rails are the crossbars."
r:
short: "R"
title: "R — window, wheel and F"
text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F."
show:
intro: "Not sure yet? Here is each mark everywhere it would have to work."
sizes: "Big and small"
sizes_text: "From a poster to the 16-pixel icon in a browser tab. Below 32 pixels both marks switch to a simplified drawing — M drops nothing, R keeps the window, the cross and the hub."
name: "With the name"
name_text: "Side by side and stacked. The wordmark is the same for both, so only the mark decides."
browser: "In the browser"
browser_text: "The tab icon at its real size, and the site header of furtka.org."
nav1: "Docs"
nav2: "Blog"
phone: "On the phone"
phone_text: "Home-screen icon — square as on Android, round as some launchers cut it."
box: "On the box"
box_text: "The text-mode welcome screen your server shows on a monitor, and the boot splash."
shared: "Shared"
shared_text: "The preview card that appears when someone posts a link to furtka.org."
colour: "In colour"
colour_text: "On light, on dark, on its own colour, and in plain text colour — each mark is one shape and takes whatever colour is around it."
final: "Seen enough?"
labels:
vote: "This one"
voted: "Your pick"
change: "Switch to this one"
total: "{n} votes so far. Thank you!"
error: "Couldn't reach the poll right now — please try again in a moment."
nojs: "Voting needs JavaScript."
note: "No cookies, no account. To count each device once, the server keeps a salted hash of your IP address and browser — not the address itself. Details in the <a href=\"/privacy/\">privacy notice</a>."
---
*Furtka* is Polish for a small garden gate — the little door in the fence
that lets you in without opening the whole thing. Both marks play with that:
one draws the letter into the gate, the other looks through the gate at a
wheel. Vote right here — or scroll on first and see both marks everywhere
they would have to work, from a poster to the tiny icon in a browser tab.

12
website/data/logo/m.json Normal file
View file

@ -0,0 +1,12 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><defs><linearGradient id=\"gmd\" x1=\".15\" y1=\"0\" x2=\".7\" y2=\"1\"><stop offset=\"0\" stop-color=\"#d95a45\"/><stop offset=\".55\" stop-color=\"#c03a28\"/><stop offset=\"1\" stop-color=\"#8a2618\"/></linearGradient><clipPath id=\"cmd\" clip-rule=\"evenodd\"><path d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></clipPath></defs><path fill=\"url(#gmd)\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/><g clip-path=\"url(#cmd)\"><path fill=\"#fff\" opacity=\".14\" d=\"M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z\"/></g></svg>",
"flat": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></svg>",
"min": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 232.44 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(1.0000) translate(-6 -2)\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></g><g fill=\"currentColor\"><path transform=\"translate(68.24 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.21 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(121.45 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.17 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.14 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(195.88 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(56.10 0) scale(1.0000) translate(-6 -2)\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z\"/></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJklEQVR42sWRvUoDURCFv9msf1hoZyFCGmEjWks2iIkSi9gIIZ2FQWx9BX0Gn0GsrUwjCSmMaB1ICBEsxMJGK8F13bHYmFzWJQQi+HX3zsyZc88VIlyvry7YCf8YZQ9huXfdUeRyEu8s03h4MfvFPFTd1K5FcA4yTzyvIrK/edO6+iVQTa9sWxJUgAmG4wXKztZtu94XqGWT0+JNdUCWGI3H9znfKVS6HzaAeDMlUGNY7hUOc41W05yquc6pwAmQnH2zi8CFFZaCvNHn69dnMTocRYU8gN3buGjUnnN33SeAetpxEdkYDGkG/QkvdBwKKJbxH4Gx5Qj0YLDWcKCSMBzEk220y0A5JoM+FmMy1EE9kyqgrMVlMJKAqpaA2Az+7An/LzA232GMWfyHzvuOAAAAAElFTkSuQmCC",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJUlEQVR42sWRz0oCYRTFf3dmkDbSbqCwTQQzPUGkgRriE6TQrnqGwKB1u54jwjbtIxzB0hdIC7fppjb92SQ1t0WMfZlOgUFnd7977uGc8wlj0Mh4yYFK0UaWABDt2ujZ2sXN0yhXzKFawnb7fgVlD0iOcB9BDrKXnUOB8JtAtYTt9vxjYIM4qFSzzc5mJGJF727fr/x4/BGnXE8v735x0Mh4yVeVnmH7NlRr6z7VDsonvEXkYNWvIeSAB03YqXxw9ewADFSKlpFZQ9leb7XPY3zM8hIWgFMHwBI89HN7t9Cp0YJ62ttXZMc4nDfK8AEcAFUS5ndEtlWlgLA4vgtmhgKTkGte583Z6GAIiykR6yBI+0fAyvgOfiGAMjexg7+K8P8CU+Md2cpUHzmzh4cAAAAASUVORK5CYII=",
"banner": " ▄▄▄▄▄▄▄▄\n ▄████████████▄\n ▄██████████████████\n ▄█████████▀▀▀▀▀▀▀████▄\n ▄██████▀ █████▄\n ██████▀ ██████\n ██████ █████████████\n ██████ ▀▀▀▀▀▀▀██████\n ██████ ██████\n ██████ ▄▄▄▄▄▄▄██████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n",
"color": "#c03a28",
"ink": "#0d0d0f"
}

12
website/data/logo/r.json Normal file
View file

@ -0,0 +1,12 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"crd\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath><linearGradient id=\"grd\" gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1=\"2\" x2=\"0\" y2=\"112\"><stop offset=\"0\" stop-color=\"#c9803f\"/><stop offset=\".6\" stop-color=\"#af6428\"/><stop offset=\"1\" stop-color=\"#9a561f\"/></linearGradient></defs><g fill=\"none\" stroke=\"url(#grd)\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#crd)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"flat": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"min": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"11\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106M8 65H96\"/><path d=\"M52 23H78M52 39H68\" stroke-linecap=\"round\"/></g><circle cx=\"52\" cy=\"65\" r=\"19\" fill=\"currentColor\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 233.17 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(68.97 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.94 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(122.17 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.90 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.87 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(196.60 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(55.74 0) scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" clip-rule=\"evenodd\"><path clip-rule=\"evenodd\" d=\"M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z\"/></clipPath></defs><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9\" clip-path=\"url(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAB4klEQVR42o2TPWhTYRSGn/Pdm7Qd/NnqpoPYqIvVguAP7SwUmlRrhw5qrx1dHF0c7OoiFElTOyhOpjFrJ6uIgihSKKYloiBCwSJ00bTJva9DbtubEKHveM77nPN+Bz4joYUgk3PGLYl+oCfR+gt8FJrLFVbLO0UDmL9xrPuQ3/3cIGuwLrQko8dhQxF6ZXI10CDQK3jR3dOYuPKouuUADqe68gYjwP3tTe9otrA6jngicRBRk6Jvkp4iKxtc3aqlZgDs5dSp84qi94iH2bnK3Z1IxckTgTM3S6veGXwQ3AEGXKToOlCv400nXV7kfgBImjazXD3tHcgWKhe2w8YDoCFj3JnUB1Ydm1v5nYQjz44DmNk9SQt+PZwCGJuv/gK+msg4sBSm7SQoMCPqjW/6zAsZ2qzXZtg7cw3o8umgUtCXNyyIR02EHn9uFr4vtfusFGQWDU5j9raZl1RkGrGkCYSsjKPenKeLghU/jplGOtd0mmdtGwSG0Y8UxqU0gB+P/pmdrZzZjT158g2mSwn4da7wZXC3fzvzGYHr9GbfC7OCUkwWUy4a7eTrCA/n1zYs4nEM54fzaxv7hvcrKwWZReAsUGw5kunI3sew9TZuFPjkA8vAAHCtZaoMQWjY5f8sXv4HrfW+WZX2x04AAAAASUVORK5CYII=",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAChUlEQVR42nWTT4jUZRjHP993fr/ZSSSKoFu4UDC/TQNLErrUVQndSaigw2o0oyRG7KkgFAKxS9TFIpp1W4I6eHCmdutSHcwgoaK10FndFkwIRNSD5u7On9/77TA7y4T1PT087/u8z/f7fd5HDKH58paHnHT3AtuwNg7yhtvgX9LEM7s+uvTXIB8GQaNaftWF3gLWdqMzwrPAVuFZxA9BeqrXC5ca1Wz/oEYAjVq2D/OeCOOVqQtnAOZeeuz+7obuTQhvS14BIHrE8qTFoT31i5/p5MHNG9NOfkVmb+XEwuzg1VMT2QMqcl1SE+j2+XseqWW7vqFd3JQUO/HNiEuBpDWsX6GUwyo2p2X+7uRh7oWZ81cBmtXs2HKpsyNER7CWovL5Rq38+qA4KRaSNbvGLb84Enpj6wbKP2E9mgQFsH9Emoxm5lQ1e6aU9w6s5Ms7giDEMLV7+sLnAq+zilpFjKy7Xam3vk1D3CpZ7UJ6OYT4LvBHVP5Oszr2Af+BZG2Oo83a2Bu5QdG/Wq6EGHaOn2h9//VrWx7uLPcWm7XsKqgN4OhRwbU1XdwLbOvrCSWItNPiEkAnp2Mh9c/bQ/f7nQW/Veqt/QY1atmk7GfTbvurL2rln2Ontx3II5x+rt56X+DmK9nHAGpWx45iPyglhyPdadDmQs6hPPFOrIOI47a+FP4Q/Gck31dwcgTpWuhTZTTSOy+FO2maPr77k4U5uve8BUC3dHjPVOubEP0EaClQ+B14BCCJuCx4GnmiUl84yf9gfPribeBAo1b+zvCp8Y2koLAYo88B9/3706+UjK4rWZloVLNVhlYM61whaDGB/CzSk8Dzw53c35l5o1130ZBvQTz7D8VtHXvXpMGjAAAAAElFTkSuQmCC",
"banner": " ▄▄▄▄▄▄▄▄▄▄▄▄\n ▄▄████▀▀████▀▀████▄▄\n ▄██▀▀ ██ ▀▀██▄\n ██▀ ██████████ ▀██\n ███ ██ ▀██\n ████▄ ███████ ██\n ██▀▀██▄▄ ██ ██\n ██ ▀▀██▄▄██████▄ ██\n ██ ▀██▀▀ ▀▀██ ██\n ███████████ ███████████\n ██ ▀██▄ ▄██▀ ██\n ██ ▄██████████████▄ ██\n ██ ▄▄██▀▀ ▀██▀ ▀▀██▄▄ ██\n ████▀▀ ██ ▀▀████\n ██▀ ██ ▀██\n ██▄▄▄▄▄▄▄▄▄▄████▄▄▄▄▄▄▄▄▄▄██\n ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
"color": "#af6428",
"ink": "#151515"
}

View file

@ -11,6 +11,13 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}" SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}"
WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}" WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}"
# The runner job runs as root inside the container; hand the trees back
# to the VM user no matter how this script ends, so the manual deploy.sh
# path keeps working even after a failed build.
if [ "$(id -u)" = 0 ]; then
trap 'chown -R "${FURTKA_OWNER:-1000:1000}" "$SRCROOT" "$WEBROOT"' EXIT
fi
echo "==> rsync website/ → $SRCROOT" echo "==> rsync website/ → $SRCROOT"
rsync -az --delete \ rsync -az --delete \
--exclude='.hugo_build.lock' \ --exclude='.hugo_build.lock' \

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.20-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer] [markup.goldmark.renderer]

View file

@ -12,6 +12,9 @@
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
</p> </p>
{{ with .Params.poll_link }}
<p class="reveal"><a class="home-poll" href="{{ . }}">{{ partial "mark-m.html" (dict "class" "" "id" "hm") }}{{ partial "mark-r.html" (dict "class" "" "id" "hr") }}<span>{{ $.Params.poll_text }} →</span></a></p>
{{ end }}
</header> </header>
{{ with .Params.intro }} {{ with .Params.intro }}

View file

@ -1,7 +1,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script>document.documentElement.classList.replace('no-js','js');</script> <script>document.documentElement.classList.replace('no-js','js');</script>
<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title> {{ if eq .Type "poll" }}{{/* Coin-flip which mark sits left/first, so neither side collects the
"first thing on the page" votes. Decided before first paint (no reflow), applied by CSS `order`,
and kept for the tab session so a reload or a vote doesn't shuffle the page under the visitor. */}}
<script>(function(){var k='furtka-logo-order',o=null;try{o=sessionStorage.getItem(k)}catch(e){}if(o!=='mr'&&o!=='rm'){o=Math.random()<0.5?'mr':'rm';try{sessionStorage.setItem(k,o)}catch(e){}}document.documentElement.setAttribute('data-logo-order',o)})();</script>
{{ end }}<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}"> <meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta name="theme-color" content="#f7f6f3" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#f7f6f3" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)">
@ -11,6 +15,10 @@
<meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}"> <meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}"> <meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}"> <meta property="og:url" content="{{ .Permalink }}">
{{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">{{ end }}
{{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}"> {{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}">
{{ range .AllTranslations }} {{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}"> <link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">

View file

@ -0,0 +1,2 @@
{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>

View file

@ -0,0 +1,2 @@
{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 104 114" aria-hidden="true" focusable="false"><defs><clipPath id="pm-rc-{{ .id }}" clip-rule="evenodd"><path clip-rule="evenodd" d="M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"/></clipPath></defs><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><path d="M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"/><path d="M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9" clip-path="url(#pm-rc-{{ .id }})"/><path d="M52 22H79M52 37H69" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g></svg>

View file

@ -0,0 +1,168 @@
{{ define "main" }}
{{- $p := .Params -}}
{{/* Display order of the two marks; flip via `order: [r, m]` in front matter so neither
side gets the "first thing on the page" advantage for the whole poll. */}}
{{- $order := $p.order | default (slice "m" "r") -}}
{{- $s := $p.show -}}
<article class="poll" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
<div class="prose">{{ .Content }}</div>
{{/* ── Quick vote ─────────────────────────────────────────── */}}
<div class="poll-grid" id="vote">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
{{ $c := index $p $k }}
<section class="poll-card" data-choice="{{ $k }}" aria-labelledby="poll-{{ $k }}-title">
<div class="poll-art poll-art--{{ $k }}">{{ $d.display | safeHTML }}</div>
<h2 id="poll-{{ $k }}-title">{{ $c.title }}</h2>
<p class="poll-desc">{{ $c.text }}</p>
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
</section>
{{ end }}
</div>
<p class="poll-status" role="status" aria-live="polite"
data-t-voted="{{ $p.labels.voted }}"
data-t-total="{{ $p.labels.total }}"
data-t-error="{{ $p.labels.error }}"
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
{{/* ── Showcase ───────────────────────────────────────────── */}}
<div class="show">
<p class="section-eyebrow show-intro">{{ $s.intro }}</p>
<section class="show-row">
<h2 class="show-h">{{ $s.sizes }}</h2>
<p class="show-p">{{ $s.sizes_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="sizes">
<figure><span class="sz sz-96">{{ $d.flat | safeHTML }}</span><figcaption>96</figcaption></figure>
<figure><span class="sz sz-48">{{ $d.flat | safeHTML }}</span><figcaption>48</figcaption></figure>
<figure><span class="sz sz-32">{{ $d.min | safeHTML }}</span><figcaption>32</figcaption></figure>
<figure><span class="sz sz-24">{{ $d.min | safeHTML }}</span><figcaption>24</figcaption></figure>
<figure><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt=""><figcaption>16</figcaption></figure>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.name }}</h2>
<p class="show-p">{{ $s.name_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="lock lock-h">{{ $d.lockup_h | safeHTML }}</div>
<div class="lock lock-s">{{ $d.lockup_s | safeHTML }}</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.browser }}</h2>
<p class="show-p">{{ $s.browser_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="mock">
<div class="tabbar"><span class="tab"><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt="">Furtka</span></div>
<div class="urlbar">https://furtka.org</div>
<div class="uihead"><span class="uihead-mark">{{ $d.flat | safeHTML }}</span><span class="uihead-name">Furtka</span><span class="uihead-nav"><span>{{ $s.nav1 }}</span><span>{{ $s.nav2 }}</span></span></div>
<div class="uibody"><i></i><i class="s"></i></div>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.phone }}</h2>
<p class="show-p">{{ $s.phone_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="tiles"><img src="/img/logo/{{ $k }}/tile.png" width="96" height="96" alt=""><img src="/img/logo/{{ $k }}/tile-round.png" width="96" height="96" alt=""></div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.box }}</h2>
<p class="show-p">{{ $s.box_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<pre class="term"><i>{{ $d.banner }}</i>
<b>Furtka is ready.</b> Open http://furtka.local</pre>
<img class="shot" src="/img/logo/{{ $k }}/splash.png" width="960" height="540" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.shared }}</h2>
<p class="show-p">{{ $s.shared_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<img class="shot" src="/img/logo/{{ $k }}/social.png" width="600" height="315" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.colour }}</h2>
<p class="show-p">{{ $s.colour_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="grounds">
<span class="ground" style="background:#f7f6f3;color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.ink }};color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.color }};color:#f7f6f3">{{ $d.flat | safeHTML }}</span>
<span class="ground ground--fg">{{ $d.flat | safeHTML }}</span>
</div>
</div>
{{ end }}
</div>
</section>
</div>
{{/* ── Vote again at the end ─────────────────────────────── */}}
<section class="poll-final">
<h2 class="show-h">{{ $s.final }}</h2>
<div class="poll-final-row">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="poll-final-card" data-choice="{{ $k }}">
<span class="poll-final-mark show-cell--{{ $k }}">{{ $d.flat | safeHTML }}</span>
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }} — {{ index $p $k "short" }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
</div>
{{ end }}
</div>
</section>
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
</article>
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
{{ end }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,9 @@
# Build-time font
`FamiljenGrotesk-SemiBold.ttf` — Familjen Grotesk, weight 600, fetched from
Google Fonts (fonts.gstatic.com, family `Familjen+Grotesk:wght@600`) on
2026-08-26. Licence: SIL Open Font License 1.1.
It is used **only** by `tools/logo-assets.py` to outline the wordmark into
SVG paths and to render the splash / social-card PNGs. The site itself ships
no webfonts.

Binary file not shown.

View file

@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Generate the logo-poll showcase assets for /logo/ from one geometry source.
python3 website/tools/logo-assets.py
Writes website/static/img/logo/{m,r}/*.png and website/data/logo/{m,r}.json.
Needs: cairosvg, Pillow, fontTools (pip). Run it on the dev box and commit
the output the site build itself needs nothing beyond Hugo.
Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill).
Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch).
"""
import base64
import io
import json
import os
import sys
import cairosvg
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
SITE = os.path.dirname(HERE)
FONT = os.path.join(HERE, "FamiljenGrotesk-SemiBold.ttf")
XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
# ---------------------------------------------------------------- geometry
M_FLAT = (
"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6"
"H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"
)
M_MIN = (
"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32"
"a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"
)
R_FRAME = "M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"
R_OUTER = "M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z"
R_HOLE = "M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"
R_SPOKES = "M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9"
R_ARMS = "M52 22H79M52 37H69"
MARKS = {
"m": {
"vb": (0, 0, 64, 64),
"box": (6, 2, 52, 58), # x, y, w, h of the drawn mark inside the viewBox
"color": "#c03a28",
"ink": "#0d0d0f",
"grad": ("#d95a45", "#c03a28", "#8a2618"),
},
"r": {
"vb": (0, 0, 104, 114),
"box": (2, 2, 100, 110),
"color": "#af6428",
"ink": "#151515",
"grad": ("#c9803f", "#af6428", "#9a561f"),
},
}
def body(k, tier, color="currentColor", uid=""):
"""SVG inner markup for mark k at tier display | flat | min."""
if k == "m":
if tier == "display":
g = MARKS["m"]["grad"]
return (
f'<defs><linearGradient id="g{uid}" x1=".15" y1="0" x2=".7" y2="1">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".55" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
f'<clipPath id="c{uid}" clip-rule="evenodd"><path d="{M_FLAT}"/></clipPath></defs>'
f'<path fill="url(#g{uid})" fill-rule="evenodd" d="{M_FLAT}"/>'
f'<g clip-path="url(#c{uid})"><path fill="#fff" opacity=".14" '
f'd="M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z"/></g>'
)
path = M_MIN if tier == "min" else M_FLAT
return f'<path fill="{color}" fill-rule="evenodd" d="{path}"/>'
if tier == "min":
return (
f'<g fill="none" stroke="{color}" stroke-width="11" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="M52 8V106M8 65H96"/>'
f'<path d="M52 23H78M52 39H68" stroke-linecap="round"/></g>'
f'<circle cx="52" cy="65" r="19" fill="{color}"/>'
)
defs = (
f'<clipPath id="c{uid}" clip-rule="evenodd"><path clip-rule="evenodd" '
f'd="{R_OUTER} {R_HOLE}"/></clipPath>'
)
if tier == "display":
g = MARKS["r"]["grad"]
defs += (
f'<linearGradient id="g{uid}" gradientUnits="userSpaceOnUse" x1="0" y1="2" x2="0" y2="112">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".6" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
)
color = f"url(#g{uid})"
return (
f"<defs>{defs}</defs>"
f'<g fill="none" stroke="{color}" stroke-width="8" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="{R_SPOKES}" clip-path="url(#c{uid})"/>'
f'<path d="{R_ARMS}" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g>'
)
def svg(k, tier, color="currentColor", uid="", cls="pm", standalone=False):
x, y, w, h = MARKS[k]["vb"]
ns = f" {XMLNS}" if standalone else ""
return (
f'<svg class="{cls}"{ns} viewBox="{x} {y} {w} {h}" aria-hidden="true" focusable="false">'
f"{body(k, tier, color, uid)}</svg>"
)
# ---------------------------------------------------------------- raster
def raster(svgtxt, w, h):
png = cairosvg.svg2png(bytestring=svgtxt.encode(), output_width=w, output_height=h)
return Image.open(io.BytesIO(png)).convert("RGBA")
def mark_png(k, tier, height, color=None):
_, _, w, h = MARKS[k]["vb"]
return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height)
# ---------------------------------------------------------------- wordmark
_font = TTFont(FONT)
_upm = _font["head"].unitsPerEm
_cap = _font["OS/2"].sCapHeight or int(_upm * 0.7)
_glyphs = _font.getGlyphSet()
_cmap = _font.getBestCmap()
def wordmark(text, size, x0=0.0, baseline=0.0, tracking=-0.028):
"""Outline text as one SVG path; returns (path_d, advance_width)."""
s = size / _upm
parts, x = [], x0
for ch in text:
name = _cmap[ord(ch)]
pen = SVGPathPen(_glyphs)
g = _glyphs[name]
g.draw(pen)
d = pen.getCommands()
if d:
parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>')
x += g.width * s + tracking * size
return "".join(parts), x - x0 - tracking * size
def lockup(k, stacked=False):
"""Mark + 'furtka', outlined, currentColor. Returns an inline SVG string."""
bx, by, bw, bh = MARKS[k]["box"]
H = 58.0 # mark height in lockup units
sc = H / bh
mw = bw * sc
size = H * 0.72 * _upm / _cap # cap height = 72 % of the mark height
if stacked:
paths, tw = wordmark("furtka", size)
gap = 12
total_w = max(mw, tw)
total_h = H + gap + size * _cap / _upm + size * 0.22
mx = (total_w - mw) / 2
tx = (total_w - tw) / 2
base = H + gap + size * _cap / _upm
paths, _ = wordmark("furtka", size, x0=tx, baseline=base)
inner = (
f'<g transform="translate({mx:.2f} 0) scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 0 {total_w:.2f} {total_h:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
gap = H * 0.28
paths, tw = wordmark("furtka", size, x0=mw + gap, baseline=H)
total_w = mw + gap + tw
inner = (
f'<g transform="scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 -4 {total_w:.2f} {H + 6:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
def lockup_png(k, W, Hpx, stacked, mark_h):
"""Rasterise the lockup on the mark's ink ground (splash / social card)."""
ink = MARKS[k]["ink"]
col = MARKS[k]["color"]
inner = lockup(k, stacked).replace("currentColor", col)
inner = inner.replace('<svg class="pm"', f"<svg {XMLNS}")
vb = inner.split('viewBox="')[1].split('"')[0].split()
vw, vh = float(vb[2]), float(vb[3])
scale = mark_h / 58.0
w, h = round(vw * scale), round(vh * scale)
im = Image.new("RGBA", (W, Hpx), ink)
im.alpha_composite(raster(inner, w, h), ((W - w) // 2, (Hpx - h) // 2))
return im
# ---------------------------------------------------------------- tiles etc.
def tile(k, size, pad=0.18, radius=None):
im = Image.new("RGBA", (size, size), MARKS[k]["ink"])
m = mark_png(k, "flat", int(size * (1 - 2 * pad)))
im.alpha_composite(m, ((size - m.width) // 2, (size - m.height) // 2))
if radius:
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius, fill=255)
im.putalpha(mask)
return im
def banner(k, cols=30):
"""Half-block console art (~30 columns) from the interface tier.
Sampled by coverage: the mark is rendered 4x and box-averaged down to
one sample per half-character, then thresholded at 50 %.
"""
_, _, w, h = MARKS[k]["vb"]
rows = round(cols * h / w)
rows += rows % 2
a = raster(svg(k, "flat", "#000", standalone=True), cols * 4, rows * 4).split()[3]
a = a.resize((cols, rows), Image.BOX)
lines = []
for y in range(0, rows, 2):
s = ""
for x in range(cols):
t = a.getpixel((x, y)) > 128
b = a.getpixel((x, y + 1)) > 128
s += "" if t and b else "" if t else "" if b else " "
lines.append(s.rstrip())
return "\n".join(lines)
def b64png(im):
buf = io.BytesIO()
im.save(buf, "PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def vote_card(path):
"""1200x630 share card for /logo/: M on cream, R on ink, side by side."""
W, H = 1200, 630
im = Image.new("RGBA", (W, H), "#f7f6f3")
ImageDraw.Draw(im).rectangle([W // 2, 0, W, H], fill=MARKS["r"]["ink"])
for k, cx in (("m", W // 4), ("r", 3 * W // 4)):
m = mark_png(k, "flat", 330)
im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2))
size = 46
d = ImageDraw.Draw(im)
for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")):
paths, tw = wordmark(text, size)
# letters via the same outlines: rasterise a tiny SVG for crisp text
svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
f'<g fill="{col}">{paths}</g></svg>')
t = raster(svgtxt, round(tw), round(size * 1.3))
im.alpha_composite(t, (cx - t.width // 2, 500))
# url line, centred on the seam
paths, tw = wordmark("furtka.org/logo", 40)
svgtxt = (f'<svg {XMLNS} viewBox="0 -40 {tw:.0f} 52"><g fill="#c03a28">{paths}</g></svg>')
t = raster(svgtxt, round(tw), 52)
pad = 18
box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3")
ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc")
box.alpha_composite(t, (pad, pad))
im.alpha_composite(box, (W // 2 - box.width // 2, 40))
im.convert("RGB").save(path, optimize=True)
def main():
vote_card(os.path.join(SITE, "static", "img", "logo", "vote-card.png"))
for k in MARKS:
out = os.path.join(SITE, "static", "img", "logo", k)
os.makedirs(out, exist_ok=True)
tile(k, 192, radius=42).save(os.path.join(out, "tile.png"), optimize=True)
tile(k, 192, radius=96).save(os.path.join(out, "tile-round.png"), optimize=True)
lockup_png(k, 960, 540, True, 190).save(os.path.join(out, "splash.png"), optimize=True)
lockup_png(k, 600, 315, False, 130).save(os.path.join(out, "social.png"), optimize=True)
data = {
"display": svg(k, "display", uid=f"{k}d"),
"flat": svg(k, "flat"),
"min": svg(k, "min"),
"lockup_h": lockup(k),
"lockup_s": lockup(k, stacked=True),
"tab16": b64png(mark_png(k, "min", 16)),
"full16": b64png(mark_png(k, "flat", 16)),
"banner": banner(k),
"color": MARKS[k]["color"],
"ink": MARKS[k]["ink"],
}
with open(os.path.join(SITE, "data", "logo", f"{k}.json"), "w") as f:
json.dump(data, f, indent=1, ensure_ascii=False)
print(k, "ok")
if __name__ == "__main__":
sys.exit(main())