Compare commits
No commits in common. "main" and "26.14-alpha" have entirely different histories.
main
...
26.14-alph
|
|
@ -12,7 +12,6 @@ on:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'website/**'
|
- 'website/**'
|
||||||
- 'ops/**'
|
|
||||||
- 'CHANGELOG.md'
|
- 'CHANGELOG.md'
|
||||||
- 'RELEASING.md'
|
- 'RELEASING.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -13,4 +13,3 @@ iso/out/
|
||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
website/.hugo_build.lock
|
website/.hugo_build.lock
|
||||||
website/hugo_stats.json
|
|
||||||
|
|
|
||||||
274
CHANGELOG.md
|
|
@ -7,272 +7,6 @@ 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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Failed-login rate limit on `/login`.** A new in-memory
|
|
||||||
`LoginAttempts` store in `furtka/auth.py` blocks brute-force attempts
|
|
||||||
after 10 failures in 15 minutes from the same (username, IP) pair,
|
|
||||||
with a 15-minute lockout. Successful logins clear the counter; a
|
|
||||||
`systemctl restart furtka` clears any stuck lockout — fine for an
|
|
||||||
alpha single-user box. Tuple-keying means a flood from one source IP
|
|
||||||
can't lock the admin out from elsewhere; an attacker can rotate IPs
|
|
||||||
to keep probing forever, but each attempt still eats the PBKDF2 cost.
|
|
||||||
Locked attempts get a `Retry-After` header so the UI can render the
|
|
||||||
cooldown.
|
|
||||||
- **Live-ISO boot USB is filtered out of the install drive picker.** On
|
|
||||||
bare-metal installs, `lsblk` reports the USB stick the live ISO
|
|
||||||
booted from as `TYPE=disk`, so it showed up in the picker alongside
|
|
||||||
the real install target — a user could in theory pick the USB they
|
|
||||||
had just booted from. `webinstaller/drives.py` now resolves
|
|
||||||
`/run/archiso/bootmnt` via `findmnt`, walks it up to its parent disk
|
|
||||||
via `lsblk -no PKNAME`, and drops that disk before scoring. On a
|
|
||||||
normal (non-live) box `/run/archiso/bootmnt` does not exist and the
|
|
||||||
picker is unchanged.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **furtka.org homepage rebuild.** Adopted the visual feel of Pascal's
|
|
||||||
prototype while keeping Furtka's voice, brand palette, and bilingual
|
|
||||||
structure: Three.js wireframe torus-knot behind the hero (color +
|
|
||||||
opacity tied to the existing `--accent` CSS var so light and dark
|
|
||||||
modes share one scene), scroll-driven camera zoom + tilt, GSAP +
|
|
||||||
ScrollTrigger card reveals, Lenis smooth scroll, gradient wordmark,
|
|
||||||
drop-shadow glow in dark mode, and a pulsing CTA pointing at
|
|
||||||
`/releases`. "What works today" / "What's coming next" lists moved
|
|
||||||
from markdown bullets into front-matter arrays and now render as
|
|
||||||
scroll-reveal cards. All vendor JS (Three.js r128, GSAP 3.12.2 +
|
|
||||||
ScrollTrigger, Lenis 1.0.33) is vendored locally under
|
|
||||||
`website/assets/js/vendor/`, fingerprinted with SRI, gated to the
|
|
||||||
homepage only, deferred so first paint isn't blocked, and
|
|
||||||
early-returned on `prefers-reduced-motion`.
|
|
||||||
- **Static-asset gzip on the furtka.org nginx (config only — needs a
|
|
||||||
deploy on forge-runner-01).** Default nginx only gzips `text/html`,
|
|
||||||
so the homepage HTML was the only asset coming back compressed. The
|
|
||||||
~600 KB `three.min.js` bundle (and the hashed CSS) were being shipped
|
|
||||||
uncompressed across the public openresty proxy. `gzip_types` in
|
|
||||||
`ops/nginx/furtka.org.conf` now covers css/js/json/xml/svg/woff2.
|
|
||||||
Needs `sudo ops/nginx/setup-vm.sh` on forge-runner-01 to take effect
|
|
||||||
— the site-deploy workflow only rebuilds Hugo, it doesn't touch the
|
|
||||||
nginx config.
|
|
||||||
|
|
||||||
## [26.15-alpha] - 2026-04-21
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **HTTPS is now opt-in; fresh installs no longer hit unbypassable
|
|
||||||
SEC_ERROR_BAD_SIGNATURE.** Every version since 26.5 shipped a
|
|
||||||
Caddyfile with a `__FURTKA_HOSTNAME__.local { tls internal }` site
|
|
||||||
block, so Caddy auto-generated a self-signed root CA + intermediate
|
|
||||||
+ leaf on first boot. That worked for first-time-ever users, but
|
|
||||||
every reinstall (or second Furtka box on the same LAN) produced a
|
|
||||||
new CA with the **same intermediate CN** (`Caddy Local Authority -
|
|
||||||
ECC Intermediate` — Caddy hardcodes it). Any browser that had ever
|
|
||||||
trusted an earlier Furtka CA got a cached intermediate with
|
|
||||||
mismatched keys, then Firefox's cert lookup substituted the cached
|
|
||||||
intermediate when validating the new box's leaf → the signature
|
|
||||||
check failed → `SEC_ERROR_BAD_SIGNATURE`, which Firefox has no
|
|
||||||
"Advanced → Accept Risk" bypass for.
|
|
||||||
- Removed the hostname site block from the default Caddyfile.
|
|
||||||
Fresh installs serve `:80` only; visiting `https://furtka.local`
|
|
||||||
now yields a clean connection-refused instead of the crypto
|
|
||||||
fault.
|
|
||||||
- Added top-level `import /etc/caddy/furtka-https.d/*.caddyfile`.
|
|
||||||
The `/settings` HTTPS toggle (via `furtka.https.set_force_https`)
|
|
||||||
now writes TWO snippets atomically — the top-level hostname +
|
|
||||||
`tls internal` block (enables `:443`) and the `:80`-scoped
|
|
||||||
redirect (forces HTTP → HTTPS) — and removes both on disable.
|
|
||||||
Caddy reloads after the pair-swap; failure rolls both back.
|
|
||||||
- Webinstaller creates `/etc/caddy/furtka-https.d/` during
|
|
||||||
post-install alongside the existing `furtka.d/`.
|
|
||||||
- `updater._refresh_caddyfile` runs a 26.14 → 26.15 migration: if
|
|
||||||
the box already had the redirect snippet on disk (user had
|
|
||||||
explicitly enabled "Force HTTPS" under the old regime), the
|
|
||||||
migration also writes the new listener snippet so HTTPS keeps
|
|
||||||
working across the upgrade.
|
|
||||||
- **`status.force_https` now reads the listener snippet, not the
|
|
||||||
redirect snippet.** A lone redirect without a `:443` listener
|
|
||||||
wouldn't actually serve HTTPS, so the listener file is the
|
|
||||||
authoritative "HTTPS is on" signal. The UI on `/settings` sees the
|
|
||||||
correct state as a result.
|
|
||||||
|
|
||||||
Known remaining UX wart: a browser that trusted a previous Furtka box
|
|
||||||
still sees `BAD_SIGNATURE` when visiting this box's `https://` after
|
|
||||||
enabling HTTPS here — the fixed intermediate CN is a Caddy-side
|
|
||||||
limitation we can't fix from Furtka. Fresh installs on a browser that
|
|
||||||
never visited another Furtka box work correctly. Workaround:
|
|
||||||
`about:networking#sts` → Forget → clear `cert9.db`.
|
|
||||||
|
|
||||||
## [26.14-alpha] - 2026-04-21
|
## [26.14-alpha] - 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -573,13 +307,7 @@ 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.20-alpha...HEAD
|
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.14-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.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
|
||||||
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
||||||
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
||||||
|
|
|
||||||
|
|
@ -102,14 +102,13 @@ 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/)
|
||||||
- [x] **ISO-build in CI** — `.forgejo/workflows/build-iso.yml` runs `iso/build.sh` on every push to `main` and publishes the resulting `.iso` as the `furtka-iso` artifact (14 d retention). Push → green run → download → test.
|
- [x] **ISO-build in CI** — `.forgejo/workflows/build-iso.yml` runs `iso/build.sh` on every push to `main` and publishes the resulting `.iso` as the `furtka-iso` artifact (14 d retention). Push → green run → download → test.
|
||||||
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. Releases `26.1-alpha`, `26.3-alpha`, and `26.4-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases) (26.2 stalled on a `jq` apt hang, fixed in 26.3). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
|
- [x] **Forgejo Releases + tag-driven release pipeline** — `.forgejo/workflows/release.yml` fires on `[0-9]*` tags, `scripts/build-release-tarball.sh` packages `furtka/` + `apps/` + `assets/` + a root VERSION, `scripts/publish-release.sh` uploads tarball + sha256 + release.json to the Forgejo releases page. Releases `26.1-alpha`, `26.3-alpha`, and `26.4-alpha` live at [releases](https://forgejo.sourcegate.online/daniel/furtka/releases) (26.2 stalled on a `jq` apt hang, fixed in 26.3). Needs one repo secret (`FORGEJO_RELEASE_TOKEN`).
|
||||||
- [x] **Walking-skeleton live ISO — end to end** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO. It boots in a Proxmox VM, DHCPs onto the LAN, shows a console welcome with `http://proksi.local:5000` (+ IP fallback), serves the Flask webinstaller, runs `archinstall --silent`, reboots the VM via a Reboot-now button, and the installed system logs in and runs `docker ps` without sudo. Build infra in [`iso/`](iso/).
|
- [x] **Walking-skeleton live ISO — end to end** — `iso/build.sh` produces a hybrid BIOS/UEFI Arch-based ISO. It boots in a Proxmox VM, DHCPs onto the LAN, shows a console welcome with `http://proksi.local:5000` (+ IP fallback), serves the Flask webinstaller, runs `archinstall --silent`, reboots the VM via a Reboot-now button, and the installed system logs in and runs `docker ps` without sudo. Build infra in [`iso/`](iso/).
|
||||||
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. The boot USB itself is also filtered: on the live ISO, `findmnt /run/archiso/bootmnt` resolves the boot partition and its parent disk is dropped from the picker.
|
- [x] **Drop loop/rom devices from drive list** — `webinstaller/drives.py` filters by `lsblk` `TYPE=disk`, so the live squashfs and CD-ROM no longer appear as install targets. Boot-USB filtering on bare metal is still TODO; see [iso/README.md](iso/README.md).
|
||||||
- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
|
- [x] **Rebrand GRUB menu** — `iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
|
||||||
- [x] **Wizard: account form → drive picker → overview → archinstall** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent` against its 4.x schema (`default_layout` disk_config + `!root-password` / `!password` sentinel keys + `custom_commands` for post-install group joins). Install log page polls a JSON endpoint and renders a phase-based progress bar with a collapsible raw log. `FURTKA_DRY_RUN=1` skips the real exec for testing.
|
- [x] **Wizard: account form → drive picker → overview → archinstall** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent` against its 4.x schema (`default_layout` disk_config + `!root-password` / `!password` sentinel keys + `custom_commands` for post-install group joins). Install log page polls a JSON endpoint and renders a phase-based progress bar with a collapsible raw log. `FURTKA_DRY_RUN=1` skips the real exec for testing.
|
||||||
- [x] **mDNS `proksi.local`** — hostname baked into the live ISO, avahi + nss-mdns in the package list, advertised as soon as network-online fires. The HTTPS + local-CA half of this milestone is still open below.
|
- [x] **mDNS `proksi.local`** — hostname baked into the live ISO, avahi + nss-mdns in the package list, advertised as soon as network-online fires. The HTTPS + local-CA half of this milestone is still open below.
|
||||||
|
|
@ -118,7 +117,7 @@ None of these nail the "your dad can set this up" experience. The installer wiza
|
||||||
- [x] **On-box web UI uplevel** — shared `/style.css` served by Caddy, persistent top nav, landing page with an "Your apps" tile grid + live status, `/apps` with real per-app icons (inlined SVG from each manifest), new `/settings` page (hostname, IP, version, kernel, RAM, Docker, uptime + Furtka-updates card). `prefers-color-scheme` light/dark.
|
- [x] **On-box web UI uplevel** — shared `/style.css` served by Caddy, persistent top nav, landing page with an "Your apps" tile grid + live status, `/apps` with real per-app icons (inlined SVG from each manifest), new `/settings` page (hostname, IP, version, kernel, RAM, Docker, uptime + Furtka-updates card). `prefers-color-scheme` light/dark.
|
||||||
- [x] **Versioned on-box layout + Phase 1 per-app updates** — `/opt/furtka/versions/<ver>/` + `current` symlink; `/var/lib/furtka/` for runtime state. `POST /api/apps/<name>/update` runs `docker compose pull` + compares digests + conditional `up -d`.
|
- [x] **Versioned on-box layout + Phase 1 per-app updates** — `/opt/furtka/versions/<ver>/` + `current` symlink; `/var/lib/furtka/` for runtime state. `POST /api/apps/<name>/update` runs `docker compose pull` + compares digests + conditional `up -d`.
|
||||||
- [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot).
|
- [x] **Phase 2 Furtka self-update** — `/settings` → Check → Update now. Downloads signed tarball (SHA256), stages, atomic symlink flip, reloads Caddy, daemon-reload, restarts services, health-checks the new api with auto-rollback on failure. CLI: `furtka update [--check]` + `furtka rollback`. Validated end-to-end on VM 2026-04-16 (`26.0-alpha` → `26.3-alpha` → rollback → reboot).
|
||||||
- [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` is fully opt-in via the `/settings` toggle (26.15-alpha); fresh installs stay HTTP-only so a half-trusted cert chain can't lock the user out. Per-box root CA generated on first enable, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. The "force HTTPS" sub-toggle still only appears once the current browser already trusts the cert.
|
- [x] **Local HTTPS Phase 1** — Caddy `tls internal` on `:443` alongside plain `:80`. Per-box root CA generated on first start, `rootCA.crt` downloadable from `/settings`, per-OS install guide at `/https-install/`. Opt-in "force HTTPS" toggle only exposes itself once the current browser already trusts the cert, so enabling it can't lock the user out. Shipped in 26.4-alpha.
|
||||||
- [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha.
|
- [x] **Post-build smoke VM on Proxmox** — `.forgejo/workflows/build-iso.yml` hands the freshly built ISO to `scripts/smoke-vm.sh`, which boots it in a throwaway VM on `pollux` (192.168.178.165) and curls the webinstaller on `:5000`. VMID range 9000–9099, last 5 kept. Green end-to-end since 26.4-alpha.
|
||||||
- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
|
- [ ] Installer wizard screens S3–S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
|
||||||
- [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`).
|
- [ ] Local HTTPS Phase 2 — dedicated local CA (not Caddy's `tls internal`), streamlined one-click install across Win/Mac/Linux/Android, and HTTPS on the live-installer wizard (`https://proksi.local:5000`).
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,25 @@
|
||||||
# Serves the Furtka landing page + live JSON on :80 (plain HTTP). HTTPS
|
# Serves the Furtka landing page + live JSON on :80 (plain HTTP) and on
|
||||||
# is **opt-in** — Caddy doesn't serve :443 until the user clicks the
|
# HTTPS via Caddy's built-in `tls internal` — locally-issued certs signed
|
||||||
# "Enable HTTPS" toggle on /settings, which drops an import snippet into
|
# by a root CA that Caddy generates on first start and stores under
|
||||||
# /etc/caddy/furtka-https.d/. Default install has NO tls site block →
|
# /var/lib/caddy/pki/authorities/local/. Static pages are read from
|
||||||
# Caddy never generates a self-signed CA / leaf cert → no
|
# /opt/furtka/current/ — updates flip the symlink and everything picks up
|
||||||
# SEC_ERROR_BAD_SIGNATURE when a user visits https://furtka.local before
|
# the new content without a Caddy restart (a `systemctl reload caddy` is
|
||||||
# they've trusted anything. That was the 26.14-era regression this file
|
# still triggered post-swap to flush the file-server's handle cache).
|
||||||
# exists to cure: the old Caddyfile always served :443 with a freshly-
|
# /apps and /api are reverse-proxied to the resource-manager API
|
||||||
# generated cert, and a browser that had ever trusted an older Furtka
|
# (furtka serve, bound to 127.0.0.1:7000).
|
||||||
# box's CA would reject the new one with an unbypassable bad-sig error.
|
|
||||||
#
|
#
|
||||||
# /apps, /api, /login, /logout, / (home), /settings are reverse-proxied
|
# Hostname templating: __FURTKA_HOSTNAME__ gets substituted with the
|
||||||
# to the resource-manager API (furtka serve, bound to 127.0.0.1:7000).
|
# install-time hostname by webinstaller/app.py on first install and by
|
||||||
# Static pages are read from /opt/furtka/current/ — updates flip the
|
# furtka.updater._refresh_caddyfile on every self-update. A bare `:443
|
||||||
# symlink and everything picks up the new content without a Caddy
|
# { tls internal }` (no hostname) never triggers leaf-cert issuance, so
|
||||||
# restart (a `systemctl reload caddy` is still triggered post-swap to
|
# SNI-based handshakes die with `SSL_ERROR_INTERNAL_ERROR_ALERT` — the
|
||||||
# flush the file-server's handle cache).
|
# 26.4-alpha regression this file exists to cure.
|
||||||
#
|
#
|
||||||
# Two snippet dirs, both silently no-op when empty:
|
# Force-HTTPS: /etc/caddy/furtka.d/*.caddyfile gets imported into the :80
|
||||||
# - /etc/caddy/furtka.d/*.caddyfile → imported inside the :80 block.
|
# block. The /api/furtka/https/force endpoint creates or removes
|
||||||
# The HTTPS toggle's "force HTTP→HTTPS redirect" snippet lands here.
|
# redirect.caddyfile there to toggle the HTTP→HTTPS redirect, then reloads
|
||||||
# - /etc/caddy/furtka-https.d/*.caddyfile → imported at TOP LEVEL, so
|
# Caddy. Glob imports silently no-op on an empty/missing directory, so the
|
||||||
# the HTTPS hostname+tls-internal site block can drop in here when
|
# toggle-off state is "no file present" rather than "empty file".
|
||||||
# the toggle is on. Hostname is substituted at toggle-time.
|
|
||||||
{
|
{
|
||||||
# Named-hostname :443 blocks would otherwise make Caddy add its own
|
# Named-hostname :443 blocks would otherwise make Caddy add its own
|
||||||
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
|
# HTTP→HTTPS redirect — but we already serve our own `:80` block and
|
||||||
|
|
@ -72,8 +70,8 @@
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
# Download the local root CA cert Caddy generated for `tls internal`.
|
# Download the local root CA cert Caddy generated for `tls internal`.
|
||||||
# Public because users need to grab it before they've trusted it.
|
# Available on both :80 and :443 so users can grab it before they've
|
||||||
# The private key next to it stays 0600 / caddy-owned.
|
# trusted it. The private key next to it stays 0600 / caddy-owned.
|
||||||
handle /rootCA.crt {
|
handle /rootCA.crt {
|
||||||
root * /var/lib/caddy/pki/authorities/local
|
root * /var/lib/caddy/pki/authorities/local
|
||||||
rewrite * /root.crt
|
rewrite * /root.crt
|
||||||
|
|
@ -91,12 +89,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTTPS opt-in: when /settings toggles HTTPS on, a snippet gets written
|
|
||||||
# into /etc/caddy/furtka-https.d/ that adds the hostname+tls-internal
|
|
||||||
# site block. Empty directory = HTTP-only (default fresh install).
|
|
||||||
import /etc/caddy/furtka-https.d/*.caddyfile
|
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
import /etc/caddy/furtka.d/*.caddyfile
|
import /etc/caddy/furtka.d/*.caddyfile
|
||||||
import furtka_routes
|
import furtka_routes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {
|
||||||
|
tls internal
|
||||||
|
import furtka_routes
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,22 @@
|
||||||
#!/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.
|
||||||
# runs at boot and every few seconds from furtka-welcome.timer, so the IP
|
set -e
|
||||||
# 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
|
|
||||||
|
|
||||||
issue=/etc/issue
|
hostname=$(cat /etc/hostname)
|
||||||
|
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 ((${#ips[@]})); then
|
if [ -n "$ip" ]; then
|
||||||
for ip in "${ips[@]}"; do
|
echo " http://${ip} (fallback if the first doesn't work)"
|
||||||
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
|
||||||
|
|
||||||
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
|
agetty --reload 2>/dev/null || true
|
||||||
printf '%s\n' "$new" > "$issue"
|
|
||||||
agetty --reload 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,11 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Furtka console welcome banner
|
Description=Furtka console welcome banner
|
||||||
# No network-online dependency on purpose: paint the banner (with a
|
After=network-online.target
|
||||||
# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
|
Wants=network-online.target
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Keep the Furtka console welcome (IP fallback) up to date
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=3s
|
|
||||||
OnUnitActiveSec=5s
|
|
||||||
AccuracySec=1s
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
|
|
@ -322,16 +322,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# 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 1–3
|
|
||||||
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).
|
|
||||||
214
furtka/api.py
|
|
@ -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, deps, dockerops, install_runner, installer, reconciler, sources
|
from furtka import auth, 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,10 +152,7 @@ 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: { name, action: 'install' | 'edit', confirmDeps?: bool }
|
current: null, // { name, action: 'install' | 'edit' }
|
||||||
// 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());
|
||||||
|
|
@ -170,7 +167,7 @@ function closeModal() {
|
||||||
modal.current = null;
|
modal.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettingsDialog(name, action, opts = {}) {
|
async function openSettingsDialog(name, action) {
|
||||||
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 =
|
||||||
|
|
@ -178,7 +175,7 @@ async function openSettingsDialog(name, action, opts = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
modal.current = { name, action, confirmDeps: !!opts.confirmDeps };
|
modal.current = { name, action };
|
||||||
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';
|
||||||
|
|
@ -224,30 +221,10 @@ 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
|
||||||
|
|
@ -284,15 +261,7 @@ async function pollInstallStatus(original) {
|
||||||
|
|
||||||
async function submitModal() {
|
async function submitModal() {
|
||||||
if (!modal.current) return;
|
if (!modal.current) return;
|
||||||
const { name, action, confirmDeps } = modal.current;
|
const { name, action } = 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.
|
||||||
|
|
@ -307,9 +276,7 @@ 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'
|
const body = action === 'install' ? { name, settings: values } : { settings: values };
|
||||||
? { 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'},
|
||||||
|
|
@ -396,37 +363,8 @@ async function refresh() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleButton(op, name, btn) {
|
async function handleButton(op, name, btn) {
|
||||||
if (op === 'install') {
|
if (op === 'install' || op === 'edit') {
|
||||||
// Check dependencies before opening the settings form. If the target
|
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
|
||||||
// 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.
|
||||||
|
|
@ -452,9 +390,6 @@ 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.
|
||||||
|
|
@ -490,11 +425,7 @@ 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 === 'error') {
|
if (stage && stage !== 'done') {
|
||||||
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 {
|
||||||
|
|
@ -665,10 +596,6 @@ 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
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -768,41 +695,7 @@ def _do_get_settings(name):
|
||||||
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"})
|
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"})
|
||||||
|
|
||||||
|
|
||||||
def _do_install_plan(name):
|
def _do_install(name, settings=None):
|
||||||
"""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
|
||||||
|
|
@ -829,25 +722,6 @@ def _do_install(name, settings=None, confirm_dependencies=False):
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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:
|
||||||
|
|
@ -856,32 +730,13 @@ def _do_install(name, settings=None, confirm_dependencies=False):
|
||||||
return 409, {"error": str(e)}
|
return 409, {"error": str(e)}
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
if plan.to_install:
|
src = installer.resolve_source(name)
|
||||||
targets = installer.install_plan(plan, settings_target=settings)
|
target = installer.install_from(src, settings=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.
|
||||||
first_app = plan.to_install[0] if plan.to_install else name
|
install_runner.write_state("pulling_image", app=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()
|
||||||
|
|
@ -946,12 +801,6 @@ 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)
|
||||||
|
|
@ -1328,16 +1177,6 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
|
f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _client_ip(self) -> str:
|
|
||||||
# Caddy's reverse_proxy appends the real TCP peer to X-Forwarded-For;
|
|
||||||
# the rightmost entry is the one Caddy added, so it's trustworthy
|
|
||||||
# even if a client spoofed an XFF of their own. Caddy is the edge —
|
|
||||||
# no upstream proxy in front of it.
|
|
||||||
xff = self.headers.get("X-Forwarded-For")
|
|
||||||
if xff:
|
|
||||||
return xff.rsplit(",", 1)[-1].strip()
|
|
||||||
return self.client_address[0]
|
|
||||||
|
|
||||||
def _handle_login(self, payload):
|
def _handle_login(self, payload):
|
||||||
username = payload.get("username") if isinstance(payload, dict) else None
|
username = payload.get("username") if isinstance(payload, dict) else None
|
||||||
password = payload.get("password") if isinstance(payload, dict) else None
|
password = payload.get("password") if isinstance(payload, dict) else None
|
||||||
|
|
@ -1361,26 +1200,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
)
|
)
|
||||||
auth.create_admin(username, password)
|
auth.create_admin(username, password)
|
||||||
else:
|
else:
|
||||||
# Tuple-keyed lockout: a flood from one IP can't lock the
|
|
||||||
# admin out from a different IP. When locked we return the
|
|
||||||
# same 429 regardless of whether the password is correct —
|
|
||||||
# no oracle, no timing leak via "would have worked."
|
|
||||||
lockout_key = (username, self._client_ip())
|
|
||||||
retry = auth.LOCKOUT.retry_after_seconds(lockout_key)
|
|
||||||
if retry > 0:
|
|
||||||
return self._json(
|
|
||||||
429,
|
|
||||||
{"error": "too many failed attempts, try again later"},
|
|
||||||
extra_headers=[("Retry-After", str(retry))],
|
|
||||||
)
|
|
||||||
if not auth.authenticate(username, password):
|
if not auth.authenticate(username, password):
|
||||||
# Register before the sleep so concurrent threads see a
|
# Cheap brute-force speed bump. werkzeug's PBKDF2 is
|
||||||
# consistent count; keep the sleep so timing can't
|
# already slow per attempt, but a fixed sleep makes
|
||||||
# distinguish "locked" from "wrong password."
|
# "try 1000 passwords over the LAN" even less fun.
|
||||||
auth.LOCKOUT.register_failure(lockout_key)
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return self._json(401, {"error": "invalid username or password"})
|
return self._json(401, {"error": "invalid username or password"})
|
||||||
auth.LOCKOUT.clear(lockout_key)
|
|
||||||
|
|
||||||
session = auth.SESSIONS.create(username)
|
session = auth.SESSIONS.create(username)
|
||||||
cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
|
cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
|
||||||
|
|
@ -1520,14 +1345,11 @@ 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/plan":
|
if self.path == "/api/apps/install":
|
||||||
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"})
|
||||||
confirm = bool(payload.get("confirm_dependencies", False))
|
status, body = _do_install(name, settings=settings)
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ One admin, one password. Passwords are PBKDF2-SHA256 hashed via
|
||||||
``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt),
|
``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt),
|
||||||
stored in /var/lib/furtka/users.json with mode 0600. Sessions live in
|
stored in /var/lib/furtka/users.json with mode 0600. Sessions live in
|
||||||
memory — a systemctl restart logs everyone out again, which is fine
|
memory — a systemctl restart logs everyone out again, which is fine
|
||||||
for an alpha single-user box. The ``LoginAttempts`` store in this
|
for an alpha single-user box.
|
||||||
module rate-limits failed logins per (username, IP) and is also
|
|
||||||
in-memory; a restart clears a stuck lockout.
|
|
||||||
|
|
||||||
On upgrade from pre-auth Furtka the users.json file does not exist
|
On upgrade from pre-auth Furtka the users.json file does not exist
|
||||||
yet; the api's GET /login detects this via ``setup_needed()`` and
|
yet; the api's GET /login detects this via ``setup_needed()`` and
|
||||||
|
|
@ -22,7 +20,6 @@ forward without re-setup; see ``furtka.passwd`` for the scrypt reader.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -179,82 +176,5 @@ class SessionStore:
|
||||||
self._by_token.clear()
|
self._by_token.clear()
|
||||||
|
|
||||||
|
|
||||||
class LoginAttempts:
|
|
||||||
"""In-memory rate-limiter for failed logins, keyed by (username, ip).
|
|
||||||
|
|
||||||
Parallels SessionStore: thread-safe, uses ``datetime.now(UTC)`` so the
|
|
||||||
same ``_FakeDatetime`` test shim works, lives only in memory so a
|
|
||||||
``systemctl restart furtka`` wipes a stuck lockout. Tuple keying means
|
|
||||||
a flood from one source IP can't lock the admin out from elsewhere
|
|
||||||
(different IP → different key) — the trade-off is that an attacker
|
|
||||||
can keep probing forever by rotating IPs, but they still eat the
|
|
||||||
PBKDF2 cost per attempt.
|
|
||||||
|
|
||||||
Stored data is a dict[key → list[datetime]] of recent failure
|
|
||||||
timestamps. Every call prunes entries older than ``WINDOW_SECONDS``,
|
|
||||||
so memory per active key is bounded by ``MAX_FAILURES``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
MAX_FAILURES = 10
|
|
||||||
WINDOW_SECONDS = 15 * 60
|
|
||||||
LOCKOUT_SECONDS = 15 * 60
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
max_failures: int = MAX_FAILURES,
|
|
||||||
window_seconds: int = WINDOW_SECONDS,
|
|
||||||
lockout_seconds: int = LOCKOUT_SECONDS,
|
|
||||||
) -> None:
|
|
||||||
self._max = max_failures
|
|
||||||
self._window = timedelta(seconds=window_seconds)
|
|
||||||
self._lockout = timedelta(seconds=lockout_seconds)
|
|
||||||
self._fails: dict[tuple[str, str], list[datetime]] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def _prune_locked(self, key: tuple[str, str], now: datetime) -> list[datetime]:
|
|
||||||
"""Drop timestamps older than the window; caller holds self._lock."""
|
|
||||||
cutoff = now - self._window
|
|
||||||
kept = [ts for ts in self._fails.get(key, ()) if ts >= cutoff]
|
|
||||||
if kept:
|
|
||||||
self._fails[key] = kept
|
|
||||||
else:
|
|
||||||
self._fails.pop(key, None)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
def register_failure(self, key: tuple[str, str]) -> None:
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
with self._lock:
|
|
||||||
self._prune_locked(key, now)
|
|
||||||
self._fails.setdefault(key, []).append(now)
|
|
||||||
|
|
||||||
def is_locked(self, key: tuple[str, str]) -> bool:
|
|
||||||
return self.retry_after_seconds(key) > 0
|
|
||||||
|
|
||||||
def retry_after_seconds(self, key: tuple[str, str]) -> int:
|
|
||||||
"""Seconds remaining on an active lockout, or 0 if not locked."""
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
with self._lock:
|
|
||||||
kept = self._prune_locked(key, now)
|
|
||||||
if len(kept) < self._max:
|
|
||||||
return 0
|
|
||||||
# Lockout runs from the oldest retained failure; once it
|
|
||||||
# falls off the window the key is effectively released.
|
|
||||||
unlock_at = kept[0] + self._lockout
|
|
||||||
remaining = (unlock_at - now).total_seconds()
|
|
||||||
if remaining <= 0:
|
|
||||||
return 0
|
|
||||||
return max(1, math.ceil(remaining))
|
|
||||||
|
|
||||||
def clear(self, key: tuple[str, str]) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._fails.pop(key, None)
|
|
||||||
|
|
||||||
def clear_all(self) -> None:
|
|
||||||
"""Test helper — wipe all failure state."""
|
|
||||||
with self._lock:
|
|
||||||
self._fails.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton used by the HTTP handler.
|
# Module-level singleton used by the HTTP handler.
|
||||||
SESSIONS = SessionStore()
|
SESSIONS = SessionStore()
|
||||||
LOCKOUT = LoginAttempts()
|
|
||||||
|
|
|
||||||
|
|
@ -204,63 +204,50 @@ 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:
|
||||||
return _sync_locked()
|
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
||||||
except CatalogError as e:
|
_validate_staging(staging, check.latest)
|
||||||
write_state("error", error=str(e))
|
except CatalogError:
|
||||||
|
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
|
||||||
|
|
||||||
def _sync_locked() -> CatalogCheck:
|
write_state("done", version=check.latest, previous=check.current)
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from furtka import deps, dockerops, installer, reconciler
|
from furtka import 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
|
||||||
|
|
||||||
|
|
@ -38,14 +37,6 @@ 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,
|
||||||
|
|
@ -67,64 +58,17 @@ 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:
|
||||||
plan = deps.plan_install(args.source)
|
src = installer.resolve_source(args.source)
|
||||||
except deps.DependencyError as e:
|
target = installer.install_from(src)
|
||||||
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}")
|
||||||
# Stage the plan file run_install consumes (it removes it after reading).
|
actions = reconciler.reconcile(apps_dir())
|
||||||
install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
|
for a in actions:
|
||||||
install_runner.plan_path().write_text(
|
print(f" {a.describe()}")
|
||||||
json.dumps({"target": args.source, "to_install": to_install})
|
return 1 if reconciler.has_errors(actions) else 0
|
||||||
)
|
|
||||||
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:
|
||||||
|
|
@ -150,13 +94,6 @@ 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
|
|
@ -1,237 +0,0 @@
|
||||||
"""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()))
|
|
||||||
|
|
@ -60,88 +60,6 @@ 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.
|
||||||
|
|
||||||
|
|
|
||||||
100
furtka/https.py
|
|
@ -6,25 +6,10 @@ sets `XDG_DATA_HOME=/var/lib`, so on the target that resolves to
|
||||||
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
|
/var/lib/caddy/pki/authorities/local/. The private key stays 0600 /
|
||||||
caddy-owned; we only ever read the public root.crt next to it.
|
caddy-owned; we only ever read the public root.crt next to it.
|
||||||
|
|
||||||
HTTPS is **opt-in** since 26.15-alpha. Default Caddyfile has no `:443`
|
This module exposes two operations:
|
||||||
site block, so `tls internal` never triggers cert issuance. The
|
- status(): current CA fingerprint + whether force-HTTPS is on
|
||||||
/settings toggle drops a snippet file into /etc/caddy/furtka-https.d/
|
- set_force_https(enabled): write/remove the Caddy import snippet that
|
||||||
that adds the hostname+tls-internal block (plus the redirect snippet
|
redirects HTTP to HTTPS, reload Caddy, roll back on failure.
|
||||||
inside /etc/caddy/furtka.d/ for HTTP→HTTPS). Disabling the toggle
|
|
||||||
removes both snippets and reloads — Caddy falls back to HTTP-only.
|
|
||||||
|
|
||||||
Why opt-in: fresh-install boxes used to always serve a self-signed
|
|
||||||
cert on :443. Any browser that had ever trusted a previous Furtka
|
|
||||||
box's local CA rejected the new cert with an unbypassable
|
|
||||||
SEC_ERROR_BAD_SIGNATURE — Firefox in particular has no "Advanced →
|
|
||||||
Accept" for that case. Making HTTPS explicit means fresh installs
|
|
||||||
never hit that trap; users who want HTTPS download the rootCA.crt
|
|
||||||
first and then click the toggle.
|
|
||||||
|
|
||||||
This module exposes:
|
|
||||||
- status(): CA fingerprint + current toggle state
|
|
||||||
- set_force_https(enabled): write/remove BOTH snippets atomically,
|
|
||||||
reload Caddy, roll back on failure.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -37,9 +22,6 @@ CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt")
|
||||||
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
|
SNIPPET_DIR = Path("/etc/caddy/furtka.d")
|
||||||
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile"
|
||||||
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n"
|
||||||
HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d")
|
|
||||||
HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile"
|
|
||||||
HOSTNAME_FILE = Path("/etc/hostname")
|
|
||||||
|
|
||||||
_PEM_RE = re.compile(
|
_PEM_RE = re.compile(
|
||||||
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
r"-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----",
|
||||||
|
|
@ -51,30 +33,6 @@ class HttpsError(Exception):
|
||||||
"""Recoverable failure from set_force_https — the caller should 5xx."""
|
"""Recoverable failure from set_force_https — the caller should 5xx."""
|
||||||
|
|
||||||
|
|
||||||
def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str:
|
|
||||||
"""Return the box's hostname, stripped. Falls back to 'furtka' so a
|
|
||||||
missing /etc/hostname doesn't produce an empty site block that Caddy
|
|
||||||
would reject at parse time."""
|
|
||||||
try:
|
|
||||||
value = hostname_file.read_text().strip()
|
|
||||||
except (FileNotFoundError, PermissionError, OSError):
|
|
||||||
return "furtka"
|
|
||||||
return value or "furtka"
|
|
||||||
|
|
||||||
|
|
||||||
def _https_snippet_content(hostname: str) -> str:
|
|
||||||
"""Caddy site block the HTTPS toggle installs at opt-in.
|
|
||||||
|
|
||||||
Serves <hostname>.local and <hostname> on :443 with Caddy's
|
|
||||||
`tls internal` (local CA auto-issuance), and imports the shared
|
|
||||||
furtka_routes snippet so the :443 listener exposes the same
|
|
||||||
routes as :80. Must be written at top-level (not inside another
|
|
||||||
site block) — that's why the Caddyfile imports furtka-https.d at
|
|
||||||
top-level rather than inside :80.
|
|
||||||
"""
|
|
||||||
return f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _ca_fingerprint(ca_path: Path) -> str | None:
|
def _ca_fingerprint(ca_path: Path) -> str | None:
|
||||||
try:
|
try:
|
||||||
pem = ca_path.read_text()
|
pem = ca_path.read_text()
|
||||||
|
|
@ -96,20 +54,13 @@ def _format_fingerprint(hex_upper: str) -> str:
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
ca_path: Path = CA_CERT_PATH,
|
ca_path: Path = CA_CERT_PATH,
|
||||||
https_snippet: Path = HTTPS_SNIPPET,
|
snippet: Path = REDIRECT_SNIPPET,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""force_https is True iff the HTTPS listener snippet exists.
|
|
||||||
|
|
||||||
Before 26.15-alpha this checked the redirect snippet instead — but
|
|
||||||
the redirect alone without a :443 listener wouldn't actually serve
|
|
||||||
HTTPS, so the listener snippet is the authoritative "HTTPS is on"
|
|
||||||
signal.
|
|
||||||
"""
|
|
||||||
fp = _ca_fingerprint(ca_path)
|
fp = _ca_fingerprint(ca_path)
|
||||||
return {
|
return {
|
||||||
"ca_available": fp is not None,
|
"ca_available": fp is not None,
|
||||||
"fingerprint_sha256": _format_fingerprint(fp) if fp else None,
|
"fingerprint_sha256": _format_fingerprint(fp) if fp else None,
|
||||||
"force_https": https_snippet.is_file(),
|
"force_https": snippet.is_file(),
|
||||||
"ca_download_url": "/rootCA.crt",
|
"ca_download_url": "/rootCA.crt",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,48 +78,29 @@ def set_force_https(
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
snippet_dir: Path = SNIPPET_DIR,
|
snippet_dir: Path = SNIPPET_DIR,
|
||||||
snippet: Path = REDIRECT_SNIPPET,
|
snippet: Path = REDIRECT_SNIPPET,
|
||||||
https_snippet_dir: Path = HTTPS_SNIPPET_DIR,
|
|
||||||
https_snippet: Path = HTTPS_SNIPPET,
|
|
||||||
hostname_file: Path = HOSTNAME_FILE,
|
|
||||||
reload_caddy=_default_reload,
|
reload_caddy=_default_reload,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Toggle HTTPS by writing or removing two snippets atomically:
|
"""Toggle the HTTP→HTTPS redirect by writing or removing the snippet
|
||||||
|
Caddy imports. Always reloads Caddy. Rolls the snippet state back on
|
||||||
1. The top-level HTTPS hostname+tls-internal block (enables :443
|
reload failure so a broken config can't leave Caddy wedged on the next
|
||||||
listener + Caddy's `tls internal` cert issuance)
|
restart.
|
||||||
2. The :80-scoped redirect snippet (forces HTTP → HTTPS)
|
|
||||||
|
|
||||||
Reload Caddy after the snippet swap. On reload failure both
|
|
||||||
snippets are reverted to their pre-call state so a bad config
|
|
||||||
can't leave Caddy wedged.
|
|
||||||
"""
|
"""
|
||||||
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
https_snippet_dir.mkdir(mode=0o755, parents=True, exist_ok=True)
|
had = snippet.is_file()
|
||||||
|
previous = snippet.read_text() if had else None
|
||||||
had_redirect = snippet.is_file()
|
|
||||||
previous_redirect = snippet.read_text() if had_redirect else None
|
|
||||||
had_https = https_snippet.is_file()
|
|
||||||
previous_https = https_snippet.read_text() if had_https else None
|
|
||||||
|
|
||||||
if enabled:
|
if enabled:
|
||||||
snippet.write_text(REDIRECT_CONTENT)
|
snippet.write_text(REDIRECT_CONTENT)
|
||||||
https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file)))
|
elif had:
|
||||||
else:
|
snippet.unlink()
|
||||||
if had_redirect:
|
|
||||||
snippet.unlink()
|
|
||||||
if had_https:
|
|
||||||
https_snippet.unlink()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reload_caddy()
|
reload_caddy()
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
_revert(snippet, previous_redirect)
|
_revert(snippet, previous)
|
||||||
_revert(https_snippet, previous_https)
|
|
||||||
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
msg = (e.stderr or e.stdout or "").strip() or f"exit {e.returncode}"
|
||||||
raise HttpsError(f"caddy reload failed: {msg}") from e
|
raise HttpsError(f"caddy reload failed: {msg}") from e
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
_revert(snippet, previous_redirect)
|
_revert(snippet, previous)
|
||||||
_revert(https_snippet, previous_https)
|
|
||||||
raise HttpsError(f"systemctl not available: {e}") from e
|
raise HttpsError(f"systemctl not available: {e}") from e
|
||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,23 @@ 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(s), copy
|
- ``furtka.api._do_install`` runs synchronously: resolve source, copy
|
||||||
the app folder(s), write .env. Those are fast, and their failures
|
the app folder, write .env, validate path settings + placeholders.
|
||||||
deserve an immediate 4xx so the install modal can surface them in-line.
|
Those are fast, and their failures deserve an immediate 4xx so the
|
||||||
|
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"
|
||||||
| "running_hooks" | "starting_container" | "done" | "error",
|
| "starting_container" | "done" | "error",
|
||||||
"updated_at": "2026-04-21T17:30:45+0200",
|
"updated_at": "2026-04-21T17:30:45+0200",
|
||||||
"app": "mosquitto", // app currently being processed
|
"app": "jellyfin",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -48,21 +40,16 @@ 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 deps, dockerops, installer
|
from furtka import dockerops
|
||||||
from furtka.manifest import SETTING_NAME_RE, Manifest, load_manifest
|
from furtka.manifest import 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."""
|
||||||
|
|
@ -72,10 +59,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -96,35 +79,6 @@ 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)
|
||||||
|
|
@ -137,163 +91,31 @@ 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 → hooks → compose up.
|
"""Docker-facing phases of the install: pull → volumes → 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()`` for every app in the plan, so each app folder,
|
``installer.install_from()``, so the app folder, .env, and manifest
|
||||||
`.env`, and manifest are on disk under ``apps_dir() / <name>``.
|
are on disk at ``apps_dir() / <name>``.
|
||||||
|
|
||||||
If ``/var/lib/furtka/install-plan.json`` exists, every app in its
|
Every phase transition is written to the state file for the UI to
|
||||||
``to_install`` is processed in order (providers before consumers). Each
|
poll. On exception the state flips to ``"error"`` with the message,
|
||||||
provider is fully up before the consumer's ``on_install`` hooks fire,
|
then the exception is re-raised so the CLI exits non-zero and
|
||||||
so a hook can ``mosquitto_passwd``/`createuser` against a live broker/DB.
|
journald has a traceback.
|
||||||
|
|
||||||
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():
|
||||||
plan = _read_plan(name)
|
target = apps_dir() / name
|
||||||
target = plan["target"]
|
manifest = load_manifest(target / "manifest.json", expected_name=name)
|
||||||
to_install = list(plan["to_install"])
|
|
||||||
try:
|
try:
|
||||||
last_manifest = None
|
write_state("pulling_image", app=name)
|
||||||
for app_name in to_install:
|
dockerops.compose_pull(target, name)
|
||||||
target_dir = apps_dir() / app_name
|
write_state("creating_volumes", app=name)
|
||||||
m = load_manifest(target_dir / "manifest.json", expected_name=app_name)
|
for short in manifest.volumes:
|
||||||
last_manifest = m
|
dockerops.ensure_volume(manifest.volume_name(short))
|
||||||
write_state("pulling_image", app=app_name, target=target)
|
write_state("starting_container", app=name)
|
||||||
dockerops.compose_pull(target_dir, app_name)
|
dockerops.compose_up(target, name)
|
||||||
write_state("creating_volumes", app=app_name, target=target)
|
write_state("done", app=name, version=manifest.version)
|
||||||
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:
|
||||||
current = read_state().get("app", target)
|
write_state("error", app=name, error=str(e))
|
||||||
write_state("error", app=current, target=target, error=str(e))
|
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -171,22 +171,13 @@ 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, dest)
|
shutil.copy2(item, target / item.name)
|
||||||
|
|
||||||
env = target / ".env"
|
env = target / ".env"
|
||||||
env_example = target / ".env.example"
|
env_example = target / ".env.example"
|
||||||
|
|
@ -242,15 +233,10 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def parse_env_text(text: str) -> dict[str, str]:
|
def _read_env(env_path: Path) -> dict[str, str]:
|
||||||
"""Parse KEY=VALUE lines from a string into a dict. Unquotes quoted values.
|
"""Parse a simple KEY=VALUE .env 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 text.splitlines():
|
for raw in env_path.read_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
|
||||||
|
|
@ -264,11 +250,6 @@ def parse_env_text(text: str) -> 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():
|
||||||
|
|
@ -326,28 +307,6 @@ 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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):
|
||||||
|
|
@ -32,18 +31,6 @@ 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
|
||||||
|
|
@ -61,7 +48,6 @@ 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"
|
||||||
|
|
@ -112,49 +98,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -189,7 +132,6 @@ 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):
|
||||||
|
|
@ -206,5 +148,4 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from furtka import deps, dockerops, installer
|
from furtka import dockerops
|
||||||
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" | "hook" | "skip" | "error"
|
kind: str # "ensure_volume" | "compose_up" | "skip"
|
||||||
target: str
|
target: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
|
|
||||||
|
|
@ -28,20 +20,13 @@ 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] = []
|
||||||
results = scan(apps_root)
|
for result in 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
|
||||||
|
|
@ -52,31 +37,6 @@ 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)
|
||||||
|
|
@ -88,56 +48,5 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,6 @@ _CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddy
|
||||||
_CADDY_SNIPPET_DIR = Path(
|
_CADDY_SNIPPET_DIR = Path(
|
||||||
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
os.environ.get("FURTKA_CADDY_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka.d"))
|
||||||
)
|
)
|
||||||
_CADDY_HTTPS_SNIPPET_DIR = Path(
|
|
||||||
os.environ.get("FURTKA_CADDY_HTTPS_SNIPPET_DIR", str(_CADDYFILE_LIVE.parent / "furtka-https.d"))
|
|
||||||
)
|
|
||||||
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
||||||
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
|
_HOSTNAME_FILE = Path(os.environ.get("FURTKA_HOSTNAME_FILE", "/etc/hostname"))
|
||||||
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
|
_CADDYFILE_HOSTNAME_MARKER = "__FURTKA_HOSTNAME__"
|
||||||
|
|
@ -173,24 +170,6 @@ def _current_hostname() -> str:
|
||||||
return name or "furtka"
|
return name or "furtka"
|
||||||
|
|
||||||
|
|
||||||
def _maybe_migrate_preserve_https() -> None:
|
|
||||||
"""26.14 → 26.15 migration: if the box already had the force-HTTPS
|
|
||||||
redirect snippet on disk, that means the user explicitly opted
|
|
||||||
into HTTPS under the old regime. Under the new opt-in regime,
|
|
||||||
HTTPS also requires a separate listener snippet — write it here so
|
|
||||||
the user's HTTPS doesn't silently break when the Caddyfile refresh
|
|
||||||
removes the default hostname block.
|
|
||||||
"""
|
|
||||||
redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile"
|
|
||||||
https_snippet = _CADDY_HTTPS_SNIPPET_DIR / "https.caddyfile"
|
|
||||||
if not redirect_snippet.is_file() or https_snippet.is_file():
|
|
||||||
return
|
|
||||||
hostname = _current_hostname()
|
|
||||||
https_snippet.write_text(
|
|
||||||
f"{hostname}.local, {hostname} {{\n\ttls internal\n\timport furtka_routes\n}}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_caddyfile(source: Path) -> bool:
|
def _refresh_caddyfile(source: Path) -> bool:
|
||||||
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
|
||||||
if the file changed (so caddy needs more than a bare reload).
|
if the file changed (so caddy needs more than a bare reload).
|
||||||
|
|
@ -201,19 +180,10 @@ def _refresh_caddyfile(source: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
return False
|
return False
|
||||||
# Snippet dirs for the /api/furtka/https/force toggle. Pre-HTTPS
|
# Snippet dir for the /api/furtka/https/force toggle. Pre-HTTPS installs
|
||||||
# installs don't have them; ensure both so the Caddyfile's glob
|
# don't have this dir; ensure it so the Caddyfile's glob import can't
|
||||||
# imports can't trip an older Caddy on missing paths during the
|
# trip an older Caddy on a missing path during the first reload.
|
||||||
# first reload. furtka-https.d is new in 26.15-alpha — older boxes
|
|
||||||
# upgrading across this version line won't have it on disk yet.
|
|
||||||
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
_CADDY_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
_CADDY_HTTPS_SNIPPET_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
||||||
# Migration: pre-26.15 Caddyfile always served :443 via tls internal,
|
|
||||||
# so a box that had the "force HTTPS" redirect toggle ON relied on
|
|
||||||
# HTTPS being there implicitly. After this Caddyfile refresh the
|
|
||||||
# hostname block is gone, so the redirect would 301 to a dead :443.
|
|
||||||
# Preserve intent by writing the HTTPS listener snippet too.
|
|
||||||
_maybe_migrate_preserve_https()
|
|
||||||
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
|
rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname())
|
||||||
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
|
if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text():
|
||||||
return False
|
return False
|
||||||
|
|
@ -226,17 +196,12 @@ 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 *and
|
Newly-linked `.timer` units are additionally `systemctl enable`d so that
|
||||||
started* so that a self-update introducing a timer (e.g. 26.5 → 26.6
|
a self-update introducing a timer (e.g. 26.5 → 26.6 adding
|
||||||
adding furtka-catalog-sync.timer, 26.19 → 26.20 adding
|
furtka-catalog-sync.timer) activates it automatically — the installer's
|
||||||
furtka-welcome.timer) activates it right away — the installer's enable
|
enable list only applies to fresh installs. A linked-but-disabled timer
|
||||||
list only applies to fresh installs, and `enable` alone only takes
|
never fires on its own, so without this step catalog sync would never
|
||||||
effect at the next boot (the bench sat on a dead welcome timer after the
|
happen on upgraded boxes.
|
||||||
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 []
|
||||||
|
|
@ -247,10 +212,9 @@ 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(current_symlink() / "assets" / "systemd" / unit_file.name)])
|
_run(["systemctl", "link", str(unit_file)])
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,13 @@ 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 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/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/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/` |
|
| `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 |
|
||||||
|
|
||||||
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`
|
||||||
|
|
@ -51,33 +49,12 @@ 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:
|
||||||
|
|
||||||
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
|
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
|
||||||
- **Browser** at `http://<hostname>.local` (default `http://furtka.local` — the form's default hostname is `furtka`; only the live-installer ISO uses `proksi`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`. HTTPS is opt-in (26.15-alpha) — flip the toggle in `/settings` to switch on Caddy's `tls internal` on `:443`, then trust `rootCA.crt` from `/settings` to clear browser warnings.
|
- **Browser** at `http://<hostname>.local` (default `http://furtka.local` — the form's default hostname is `furtka`; only the live-installer ISO uses `proksi`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`. Since 26.4-alpha, `https://<hostname>.local` is also served via Caddy's `tls internal` — trust `rootCA.crt` from `/settings` to clear browser warnings.
|
||||||
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
|
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
|
||||||
|
|
||||||
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
|
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
|
||||||
|
|
@ -85,4 +62,5 @@ This is a demo shell — no Authentik, no app store yet. The landing page lives
|
||||||
## Known rough edges
|
## Known rough edges
|
||||||
|
|
||||||
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".
|
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".
|
||||||
- **Live-installer wizard is still HTTP-only**. `http://proksi.local:5000` during install has no TLS; once the box reboots, Caddy can serve `tls internal` on `:443` if the user opts in via `/settings` (26.15-alpha), but bringing TLS to the wizard itself is a later milestone.
|
- **Live-installer wizard is still HTTP-only**. `http://proksi.local:5000` during install has no TLS; the installed box gets Caddy + `tls internal` on `:443` once it reboots (26.4-alpha), but bringing the same story to the wizard itself is a later milestone.
|
||||||
|
- **Boot USB could appear as an install target on bare metal**. On a VM the ISO is a CD-ROM (filtered) and SATA is the only disk, so the picker only shows the install target. On bare metal with a USB stick, the USB is `TYPE=disk` and shows up alongside the real install drive; a user could in theory pick the USB they just booted from. Mitigating this needs detecting the boot media (via `findmnt /run/archiso/bootmnt` or similar) and filtering it out in `webinstaller/drives.py`.
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
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 (and an IP fallback) will
|
on your network. The exact URL will appear below.
|
||||||
appear here in a few seconds.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# No network-online dependency on purpose: the first run should paint the
|
After=network-online.target
|
||||||
# banner (with a "no IP yet" fallback) as early as possible; the timer
|
Wants=network-online.target
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Keep the Furtka console welcome (IP fallback) up to date
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=3s
|
|
||||||
OnUnitActiveSec=5s
|
|
||||||
AccuracySec=1s
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../furtka-issue.timer
|
|
||||||
|
|
@ -2,40 +2,22 @@
|
||||||
# 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.
|
# doesn't work. Reload at the end nudges agetty to redraw.
|
||||||
#
|
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
|
|
||||||
|
|
||||||
issue=/etc/issue
|
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||||
|
|
||||||
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 ((${#ips[@]})); then
|
if [ -n "$ip" ]; then
|
||||||
for ip in "${ips[@]}"; do
|
echo " http://${ip}:5000 (fallback if the first doesn't work)"
|
||||||
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
|
||||||
echo " Need a shell? Log in as root (no password)."
|
} > /etc/issue
|
||||||
echo
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
|
agetty --reload 2>/dev/null || true
|
||||||
printf '%s\n' "$new" > "$issue"
|
|
||||||
agetty --reload 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -8,43 +8,10 @@ server {
|
||||||
|
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_comp_level 6;
|
|
||||||
gzip_types
|
|
||||||
text/css
|
|
||||||
text/plain
|
|
||||||
text/xml
|
|
||||||
application/javascript
|
|
||||||
application/json
|
|
||||||
application/xml
|
|
||||||
application/rss+xml
|
|
||||||
application/atom+xml
|
|
||||||
image/svg+xml
|
|
||||||
font/woff
|
|
||||||
font/woff2;
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Rate limit for the poll API (http context; included from conf.d).
|
|
||||||
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "furtka"
|
name = "furtka"
|
||||||
version = "26.20-alpha"
|
version = "26.14-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"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ 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.
|
||||||
|
|
@ -49,10 +48,9 @@ def fake_dirs(tmp_path, monkeypatch):
|
||||||
from furtka import install_runner
|
from furtka import install_runner
|
||||||
|
|
||||||
importlib.reload(install_runner)
|
importlib.reload(install_runner)
|
||||||
# Scrub any sessions or lockout counters that leaked from a prior
|
# Scrub any sessions that leaked from a prior test — the SESSIONS
|
||||||
# test — both stores are module-level.
|
# store is module-level.
|
||||||
auth.SESSIONS.clear()
|
auth.SESSIONS.clear()
|
||||||
auth.LOCKOUT.clear_all()
|
|
||||||
return apps, bundled
|
return apps, bundled
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -259,136 +257,6 @@ 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:
|
||||||
|
|
@ -732,130 +600,6 @@ def test_post_login_rejects_wrong_password(fake_dirs):
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
def _post_wrong_login(port, username="daniel", password="nope"):
|
|
||||||
req = _request(
|
|
||||||
port,
|
|
||||||
"/login",
|
|
||||||
method="POST",
|
|
||||||
body={"username": username, "password": password},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req)
|
|
||||||
raise AssertionError("expected HTTPError")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_login_locks_out_after_repeated_failures(fake_dirs, monkeypatch):
|
|
||||||
auth.create_admin("daniel", "hunter2-pw")
|
|
||||||
# Flatten the 0.5s speed-bump so the test doesn't take 5 seconds.
|
|
||||||
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
|
||||||
server, port = _start_server()
|
|
||||||
try:
|
|
||||||
for _ in range(auth.LoginAttempts.MAX_FAILURES):
|
|
||||||
err = _post_wrong_login(port)
|
|
||||||
assert err.code == 401
|
|
||||||
err = _post_wrong_login(port)
|
|
||||||
assert err.code == 429
|
|
||||||
assert err.headers.get("Retry-After") is not None
|
|
||||||
assert int(err.headers["Retry-After"]) > 0
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_login_429_masks_correctness(fake_dirs, monkeypatch):
|
|
||||||
"""Once locked, the correct password must also get 429 — no oracle."""
|
|
||||||
auth.create_admin("daniel", "hunter2-pw")
|
|
||||||
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
|
||||||
server, port = _start_server()
|
|
||||||
try:
|
|
||||||
for _ in range(auth.LoginAttempts.MAX_FAILURES):
|
|
||||||
_post_wrong_login(port)
|
|
||||||
req = _request(
|
|
||||||
port,
|
|
||||||
"/login",
|
|
||||||
method="POST",
|
|
||||||
body={"username": "daniel", "password": "hunter2-pw"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req)
|
|
||||||
raise AssertionError("expected 429")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
assert e.code == 429
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_login_success_clears_lockout_counter(fake_dirs, monkeypatch):
|
|
||||||
auth.create_admin("daniel", "hunter2-pw")
|
|
||||||
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
|
||||||
server, port = _start_server()
|
|
||||||
try:
|
|
||||||
# Get close to the threshold, then log in successfully.
|
|
||||||
for _ in range(auth.LoginAttempts.MAX_FAILURES - 1):
|
|
||||||
_post_wrong_login(port)
|
|
||||||
req = _request(
|
|
||||||
port,
|
|
||||||
"/login",
|
|
||||||
method="POST",
|
|
||||||
body={"username": "daniel", "password": "hunter2-pw"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
assert r.status == 200
|
|
||||||
# Counter must have been cleared: another full MAX_FAILURES-1
|
|
||||||
# fails shouldn't trigger 429.
|
|
||||||
for _ in range(auth.LoginAttempts.MAX_FAILURES - 1):
|
|
||||||
err = _post_wrong_login(port)
|
|
||||||
assert err.code == 401
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_login_setup_not_rate_limited(fake_dirs, monkeypatch):
|
|
||||||
"""First-run setup is never auth-ed against a hash, so the lockout
|
|
||||||
must not apply — otherwise a clumsy admin could lock themselves out
|
|
||||||
of a box that has no admin yet."""
|
|
||||||
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
|
||||||
server, port = _start_server()
|
|
||||||
try:
|
|
||||||
# Many mismatched setup submissions (400s) — no 429 should appear.
|
|
||||||
for _ in range(auth.LoginAttempts.MAX_FAILURES + 3):
|
|
||||||
req = _request(
|
|
||||||
port,
|
|
||||||
"/login",
|
|
||||||
method="POST",
|
|
||||||
body={
|
|
||||||
"username": "daniel",
|
|
||||||
"password": "longenough",
|
|
||||||
"password2": "different",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req)
|
|
||||||
raise AssertionError("expected 400")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
assert e.code == 400
|
|
||||||
# Then a good setup still succeeds.
|
|
||||||
req = _request(
|
|
||||||
port,
|
|
||||||
"/login",
|
|
||||||
method="POST",
|
|
||||||
body={
|
|
||||||
"username": "daniel",
|
|
||||||
"password": "longenough",
|
|
||||||
"password2": "longenough",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
assert r.status == 200
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_logout_revokes_session(fake_dirs, admin_session):
|
def test_post_logout_revokes_session(fake_dirs, admin_session):
|
||||||
server, port = _start_server()
|
server, port = _start_server()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -195,34 +195,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ from furtka import auth
|
||||||
def tmp_users_file(tmp_path, monkeypatch):
|
def tmp_users_file(tmp_path, monkeypatch):
|
||||||
path = tmp_path / "users.json"
|
path = tmp_path / "users.json"
|
||||||
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
|
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
|
||||||
# Sessions and lockout state are module-level; wipe between tests so
|
# Sessions are module-level; wipe between tests so one doesn't leak a
|
||||||
# one doesn't leak a valid token (or a stale failure counter) into
|
# valid token into the next.
|
||||||
# the next.
|
|
||||||
auth.SESSIONS.clear()
|
auth.SESSIONS.clear()
|
||||||
auth.LOCKOUT.clear_all()
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -152,79 +150,3 @@ class _FakeDatetime:
|
||||||
if tz is None:
|
if tz is None:
|
||||||
return self._fixed.replace(tzinfo=None)
|
return self._fixed.replace(tzinfo=None)
|
||||||
return self._fixed.astimezone(tz)
|
return self._fixed.astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
# ---- Login attempts / lockout ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_under_threshold_still_allowed(tmp_users_file):
|
|
||||||
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
|
||||||
key = ("daniel", "10.0.0.1")
|
|
||||||
for _ in range(2):
|
|
||||||
store.register_failure(key)
|
|
||||||
assert store.is_locked(key) is False
|
|
||||||
assert store.retry_after_seconds(key) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_triggers_at_threshold(tmp_users_file):
|
|
||||||
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
|
||||||
key = ("daniel", "10.0.0.1")
|
|
||||||
for _ in range(3):
|
|
||||||
store.register_failure(key)
|
|
||||||
assert store.is_locked(key) is True
|
|
||||||
assert store.retry_after_seconds(key) > 0
|
|
||||||
assert store.retry_after_seconds(key) <= 60
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_window_decay(tmp_users_file, monkeypatch):
|
|
||||||
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
|
||||||
key = ("daniel", "10.0.0.1")
|
|
||||||
for _ in range(3):
|
|
||||||
store.register_failure(key)
|
|
||||||
assert store.is_locked(key) is True
|
|
||||||
# Jump 2 minutes ahead — all failures are older than the window
|
|
||||||
# and should be pruned on the next check.
|
|
||||||
monkeypatch.setattr(
|
|
||||||
auth,
|
|
||||||
"datetime",
|
|
||||||
_FakeDatetime(datetime.now(UTC) + timedelta(seconds=121)),
|
|
||||||
)
|
|
||||||
assert store.is_locked(key) is False
|
|
||||||
assert store.retry_after_seconds(key) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_clear_resets(tmp_users_file):
|
|
||||||
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
|
||||||
key = ("daniel", "10.0.0.1")
|
|
||||||
store.register_failure(key)
|
|
||||||
store.register_failure(key)
|
|
||||||
assert store.is_locked(key) is True
|
|
||||||
store.clear(key)
|
|
||||||
assert store.is_locked(key) is False
|
|
||||||
assert store.retry_after_seconds(key) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_keys_are_independent(tmp_users_file):
|
|
||||||
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
|
||||||
locked = ("daniel", "1.1.1.1")
|
|
||||||
other_ip = ("daniel", "2.2.2.2")
|
|
||||||
other_user = ("robert", "1.1.1.1")
|
|
||||||
store.register_failure(locked)
|
|
||||||
store.register_failure(locked)
|
|
||||||
assert store.is_locked(locked) is True
|
|
||||||
assert store.is_locked(other_ip) is False
|
|
||||||
assert store.is_locked(other_user) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_lockout_clear_all_wipes_every_key(tmp_users_file):
|
|
||||||
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
|
||||||
a = ("daniel", "1.1.1.1")
|
|
||||||
b = ("robert", "2.2.2.2")
|
|
||||||
store.register_failure(a)
|
|
||||||
store.register_failure(a)
|
|
||||||
store.register_failure(b)
|
|
||||||
store.register_failure(b)
|
|
||||||
assert store.is_locked(a) and store.is_locked(b)
|
|
||||||
store.clear_all()
|
|
||||||
assert not store.is_locked(a)
|
|
||||||
assert not store.is_locked(b)
|
|
||||||
|
|
|
||||||
|
|
@ -331,21 +331,3 @@ 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"]
|
|
||||||
|
|
|
||||||
|
|
@ -103,98 +103,3 @@ 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()
|
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
"""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"]
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -95,23 +95,3 @@ def test_drive_type_label_nvme_ssd_hdd():
|
||||||
|
|
||||||
def test_parse_lsblk_handles_empty_output():
|
def test_parse_lsblk_handles_empty_output():
|
||||||
assert parse_lsblk_output("") == []
|
assert parse_lsblk_output("") == []
|
||||||
|
|
||||||
|
|
||||||
def test_parse_lsblk_drops_boot_usb(monkeypatch):
|
|
||||||
import drives
|
|
||||||
|
|
||||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
|
||||||
output = "sda 500G disk\nsdb 16G disk\nnvme0n1 1T disk\n"
|
|
||||||
devices = parse_lsblk_output(output, boot_disk="sdb")
|
|
||||||
names = [d["name"] for d in devices]
|
|
||||||
assert "/dev/sdb" not in names
|
|
||||||
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_lsblk_no_boot_disk_keeps_all(monkeypatch):
|
|
||||||
import drives
|
|
||||||
|
|
||||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
|
||||||
output = "sda 500G disk\nsdb 16G disk\n"
|
|
||||||
names = [d["name"] for d in parse_lsblk_output(output, boot_disk=None)]
|
|
||||||
assert set(names) == {"/dev/sda", "/dev/sdb"}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
"""Tests for furtka.https — fingerprint extraction + HTTPS toggle.
|
"""Tests for furtka.https — fingerprint extraction + force-HTTPS toggle.
|
||||||
|
|
||||||
Since 26.15-alpha the toggle writes/removes TWO snippets atomically:
|
|
||||||
- The top-level HTTPS listener snippet (enables :443 + tls internal)
|
|
||||||
- The :80-scoped redirect snippet (forces HTTP → HTTPS)
|
|
||||||
|
|
||||||
The fingerprint case uses a throwaway self-signed EC cert with a known
|
The fingerprint case uses a throwaway self-signed EC cert with a known
|
||||||
reference fingerprint (computed once via `openssl x509 -fingerprint
|
reference fingerprint (computed once via `openssl x509 -fingerprint
|
||||||
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
|
-sha256 -noout`) so we verify the PEM → DER → SHA256 path without a
|
||||||
runtime subprocess dependency. The toggle cases stub the caddy reload
|
runtime subprocess dependency. The toggle cases stub the caddy reload
|
||||||
so we assert both snippet files are written / removed together and that
|
so we assert the snippet file is written / removed and that reload
|
||||||
reload failures roll BOTH state back.
|
failures roll state back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -38,22 +34,6 @@ _TEST_CERT_FP_SHA256 = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _paths(tmp_path):
|
|
||||||
"""Return the four paths the toggle touches, in a dict for kwargs
|
|
||||||
spreading. Keeps each test's fixture boilerplate small."""
|
|
||||||
return {
|
|
||||||
"snippet_dir": tmp_path / "furtka.d",
|
|
||||||
"snippet": tmp_path / "furtka.d" / "redirect.caddyfile",
|
|
||||||
"https_snippet_dir": tmp_path / "furtka-https.d",
|
|
||||||
"https_snippet": tmp_path / "furtka-https.d" / "https.caddyfile",
|
|
||||||
"hostname_file": tmp_path / "etc_hostname",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _prepare_hostname(tmp_path, value="testbox"):
|
|
||||||
(tmp_path / "etc_hostname").write_text(f"{value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ca_fingerprint_matches_openssl(tmp_path):
|
def test_ca_fingerprint_matches_openssl(tmp_path):
|
||||||
cert = tmp_path / "root.crt"
|
cert = tmp_path / "root.crt"
|
||||||
cert.write_text(_TEST_CERT_PEM)
|
cert.write_text(_TEST_CERT_PEM)
|
||||||
|
|
@ -73,7 +53,7 @@ def test_ca_fingerprint_no_pem_block(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_status_no_ca_no_snippet(tmp_path):
|
def test_status_no_ca_no_snippet(tmp_path):
|
||||||
s = https.status(ca_path=tmp_path / "root.crt", https_snippet=tmp_path / "https.caddyfile")
|
s = https.status(ca_path=tmp_path / "root.crt", snippet=tmp_path / "redirect.caddyfile")
|
||||||
assert s == {
|
assert s == {
|
||||||
"ca_available": False,
|
"ca_available": False,
|
||||||
"fingerprint_sha256": None,
|
"fingerprint_sha256": None,
|
||||||
|
|
@ -82,135 +62,105 @@ def test_status_no_ca_no_snippet(tmp_path):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_status_with_ca_and_https_snippet(tmp_path):
|
def test_status_with_ca_and_snippet(tmp_path):
|
||||||
ca = tmp_path / "root.crt"
|
ca = tmp_path / "root.crt"
|
||||||
ca.write_text(_TEST_CERT_PEM)
|
ca.write_text(_TEST_CERT_PEM)
|
||||||
https_snip = tmp_path / "https.caddyfile"
|
snippet = tmp_path / "redirect.caddyfile"
|
||||||
https_snip.write_text("furtka.local, furtka {\n\ttls internal\n\timport furtka_routes\n}\n")
|
snippet.write_text(https.REDIRECT_CONTENT)
|
||||||
s = https.status(ca_path=ca, https_snippet=https_snip)
|
s = https.status(ca_path=ca, snippet=snippet)
|
||||||
assert s["ca_available"] is True
|
assert s["ca_available"] is True
|
||||||
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
assert s["fingerprint_sha256"] == _TEST_CERT_FP_SHA256
|
||||||
assert s["force_https"] is True
|
assert s["force_https"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_status_force_reflects_https_snippet_not_redirect(tmp_path):
|
def test_set_force_enable_writes_snippet_and_reloads(tmp_path):
|
||||||
"""Authoritative signal for "HTTPS is on" is the listener snippet —
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
a lone redirect without a :443 listener wouldn't actually serve
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
HTTPS, so the status must NOT report it as on. Locks 26.15 semantic."""
|
|
||||||
ca = tmp_path / "root.crt"
|
|
||||||
ca.write_text(_TEST_CERT_PEM)
|
|
||||||
s = https.status(ca_path=ca, https_snippet=tmp_path / "does-not-exist.caddyfile")
|
|
||||||
assert s["force_https"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path):
|
|
||||||
_prepare_hostname(tmp_path)
|
|
||||||
p = _paths(tmp_path)
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_reload():
|
def fake_reload():
|
||||||
calls.append("reload")
|
calls.append("reload")
|
||||||
|
|
||||||
result = https.set_force_https(True, reload_caddy=fake_reload, **p)
|
result = https.set_force_https(
|
||||||
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=fake_reload
|
||||||
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
assert p["snippet"].read_text() == https.REDIRECT_CONTENT
|
assert snippet.read_text() == https.REDIRECT_CONTENT
|
||||||
written = p["https_snippet"].read_text()
|
|
||||||
assert "testbox.local, testbox" in written
|
|
||||||
assert "tls internal" in written
|
|
||||||
assert "import furtka_routes" in written
|
|
||||||
assert calls == ["reload"]
|
assert calls == ["reload"]
|
||||||
|
|
||||||
|
|
||||||
def test_set_force_uses_fallback_hostname_when_file_missing(tmp_path):
|
def test_set_force_disable_removes_snippet(tmp_path):
|
||||||
# No /etc/hostname → fall back to 'furtka' so Caddy gets a parseable
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
# block instead of an empty hostname that would fail config load.
|
snippet_dir.mkdir()
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
result = https.set_force_https(True, reload_caddy=lambda: None, **p)
|
snippet.write_text(https.REDIRECT_CONTENT)
|
||||||
assert result is True
|
|
||||||
assert "furtka.local, furtka" in p["https_snippet"].read_text()
|
|
||||||
|
|
||||||
|
result = https.set_force_https(
|
||||||
def test_set_force_disable_removes_both_snippets(tmp_path):
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
||||||
_prepare_hostname(tmp_path)
|
)
|
||||||
p = _paths(tmp_path)
|
|
||||||
p["snippet_dir"].mkdir()
|
|
||||||
p["https_snippet_dir"].mkdir()
|
|
||||||
p["snippet"].write_text(https.REDIRECT_CONTENT)
|
|
||||||
p["https_snippet"].write_text("furtka.local { tls internal }\n")
|
|
||||||
|
|
||||||
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert not p["snippet"].exists()
|
assert not snippet.exists()
|
||||||
assert not p["https_snippet"].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
def test_set_force_disable_is_idempotent_when_already_off(tmp_path):
|
||||||
p = _paths(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
result = https.set_force_https(False, reload_caddy=lambda: None, **p)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
|
|
||||||
|
result = https.set_force_https(
|
||||||
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=lambda: None
|
||||||
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
assert not p["snippet"].exists()
|
assert not snippet.exists()
|
||||||
assert not p["https_snippet"].exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_reload_failure_rolls_back_enable(tmp_path):
|
def test_reload_failure_rolls_back_enable(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
|
|
||||||
def failing_reload():
|
def failing_reload():
|
||||||
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
|
with pytest.raises(https.HttpsError, match="caddy reload failed: bad config"):
|
||||||
https.set_force_https(True, reload_caddy=failing_reload, **p)
|
https.set_force_https(
|
||||||
# Rollback: since neither snippet existed before, neither exists after.
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
||||||
assert not p["snippet"].exists()
|
)
|
||||||
assert not p["https_snippet"].exists()
|
# Rollback: since snippet didn't exist before, it must not exist after.
|
||||||
|
assert not snippet.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_reload_failure_rolls_back_disable(tmp_path):
|
def test_reload_failure_rolls_back_disable(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet_dir.mkdir()
|
||||||
p["snippet_dir"].mkdir()
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
p["https_snippet_dir"].mkdir()
|
original = "redir https://{host}{uri} permanent\n# marker\n"
|
||||||
original_redirect = "redir https://{host}{uri} permanent\n# marker\n"
|
snippet.write_text(original)
|
||||||
original_https = "# old https block\nfurtka.local { tls internal }\n"
|
|
||||||
p["snippet"].write_text(original_redirect)
|
|
||||||
p["https_snippet"].write_text(original_https)
|
|
||||||
|
|
||||||
def failing_reload():
|
def failing_reload():
|
||||||
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
raise subprocess.CalledProcessError(1, ["systemctl"], stderr="bad config")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError):
|
with pytest.raises(https.HttpsError):
|
||||||
https.set_force_https(False, reload_caddy=failing_reload, **p)
|
https.set_force_https(
|
||||||
# Rollback: both snippets are restored to their exact prior contents.
|
False, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=failing_reload
|
||||||
assert p["snippet"].read_text() == original_redirect
|
)
|
||||||
assert p["https_snippet"].read_text() == original_https
|
# Rollback: snippet is restored to its exact prior contents.
|
||||||
|
assert snippet.read_text() == original
|
||||||
|
|
||||||
|
|
||||||
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
def test_systemctl_missing_raises_and_rolls_back(tmp_path):
|
||||||
_prepare_hostname(tmp_path)
|
snippet_dir = tmp_path / "furtka.d"
|
||||||
p = _paths(tmp_path)
|
snippet = snippet_dir / "redirect.caddyfile"
|
||||||
|
|
||||||
def missing_systemctl():
|
def missing_systemctl():
|
||||||
raise FileNotFoundError(2, "No such file", "systemctl")
|
raise FileNotFoundError(2, "No such file", "systemctl")
|
||||||
|
|
||||||
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
with pytest.raises(https.HttpsError, match="systemctl not available"):
|
||||||
https.set_force_https(True, reload_caddy=missing_systemctl, **p)
|
https.set_force_https(
|
||||||
assert not p["snippet"].exists()
|
True, snippet_dir=snippet_dir, snippet=snippet, reload_caddy=missing_systemctl
|
||||||
assert not p["https_snippet"].exists()
|
)
|
||||||
|
assert not snippet.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_snippet_content_is_caddy_redir_directive():
|
def test_redirect_snippet_content_is_caddy_redir_directive():
|
||||||
# Lock the exact directive. A regression here silently stops the
|
# Lock the exact directive. A regression here silently stops the
|
||||||
# redirect from taking effect even though the file-swap looks fine.
|
# redirect from taking effect even though the file-swap looks fine.
|
||||||
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"
|
assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent"
|
||||||
|
|
||||||
|
|
||||||
def test_https_snippet_content_has_tls_internal_and_routes(tmp_path):
|
|
||||||
# Lock the shape of the opt-in HTTPS listener block. Caddy parses
|
|
||||||
# this verbatim — changing the shape without updating the test
|
|
||||||
# risks shipping a silently-broken Caddyfile import.
|
|
||||||
s = https._https_snippet_content("mybox")
|
|
||||||
assert "mybox.local, mybox {" in s
|
|
||||||
assert "\ttls internal" in s
|
|
||||||
assert "\timport furtka_routes" in s
|
|
||||||
assert s.endswith("}\n")
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ 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
|
||||||
|
|
@ -34,7 +33,7 @@ def runner(tmp_path, monkeypatch):
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _write_installed_app(apps_dir: Path, name: str = "fileshare", **overrides):
|
def _write_installed_app(apps_dir: Path, name: str = "fileshare"):
|
||||||
app = apps_dir / name
|
app = apps_dir / name
|
||||||
app.mkdir()
|
app.mkdir()
|
||||||
manifest = {
|
manifest = {
|
||||||
|
|
@ -45,7 +44,6 @@ def _write_installed_app(apps_dir: Path, name: str = "fileshare", **overrides):
|
||||||
"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")
|
||||||
|
|
@ -177,304 +175,3 @@ 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")
|
|
||||||
|
|
|
||||||
|
|
@ -73,31 +73,6 @@ 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"
|
||||||
|
|
@ -380,85 +355,3 @@ 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"]
|
|
||||||
|
|
|
||||||
|
|
@ -191,104 +191,3 @@ 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)
|
|
||||||
|
|
|
||||||
|
|
@ -133,149 +133,3 @@ 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"])
|
|
||||||
|
|
|
||||||
|
|
@ -316,17 +316,14 @@ 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"]
|
||||||
# Three calls for the newly-linked timer: link + enable + start. The
|
# Two calls for the newly-linked timer: systemctl link + systemctl enable.
|
||||||
# already-linked service is untouched. `enable` alone only arms the
|
# The already-linked service is untouched. Timers need the follow-up
|
||||||
# timer for the next boot; `start` makes it fire on the running box
|
# `enable` so self-updates that introduce new timers don't leave them
|
||||||
# (26.20 left furtka-welcome.timer dead until reboot without it).
|
# dormant — fresh installs get their enable via the webinstaller.
|
||||||
assert len(seen) == 3
|
assert len(seen) == 2
|
||||||
assert seen[0][:2] == ["systemctl", "link"]
|
assert seen[0][:2] == ["systemctl", "link"]
|
||||||
# Linked via the `current` symlink, not the versioned dir being scanned,
|
assert seen[0][2].endswith("furtka-bar.timer")
|
||||||
# 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):
|
||||||
|
|
|
||||||
|
|
@ -122,39 +122,19 @@ def test_caddyfile_asset_serves_from_current():
|
||||||
assert "root * /var/lib/furtka" in caddy
|
assert "root * /var/lib/furtka" in caddy
|
||||||
|
|
||||||
|
|
||||||
def _strip_caddy_comments(text: str) -> str:
|
def test_caddyfile_serves_both_http_and_https():
|
||||||
"""Remove # comments + blank lines so string-match assertions can
|
# :80 stays so users who haven't installed the CA still reach the box;
|
||||||
target actual Caddyfile directives, not the leading doc block.
|
# HTTPS is served via a named-hostname block so Caddy's `tls internal`
|
||||||
Comment intro is ``#`` at start-of-line or preceded by whitespace."""
|
# has something to issue a leaf cert for. A bare `:443 { tls internal }`
|
||||||
out = []
|
# never triggers issuance — that was the 26.4-alpha regression.
|
||||||
for line in text.splitlines():
|
caddy = (ASSETS / "Caddyfile").read_text()
|
||||||
stripped = line.split("#", 1)[0].rstrip()
|
|
||||||
if stripped:
|
|
||||||
out.append(stripped)
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def test_caddyfile_serves_http_by_default_https_opt_in():
|
|
||||||
# 26.15-alpha: HTTPS is opt-in. The default Caddyfile has a :80 block
|
|
||||||
# and imports /etc/caddy/furtka-https.d/*.caddyfile at top level —
|
|
||||||
# the /settings HTTPS toggle drops the hostname+tls-internal block
|
|
||||||
# into that dir when the user explicitly enables HTTPS. Default
|
|
||||||
# Caddyfile therefore contains no `tls internal` directive anywhere;
|
|
||||||
# if a future refactor puts it back, every fresh install regresses
|
|
||||||
# to the 26.14-era BAD_SIGNATURE trap. Strip comments first because
|
|
||||||
# the doc-block DOES mention `tls internal` in prose.
|
|
||||||
caddy_full = (ASSETS / "Caddyfile").read_text()
|
|
||||||
caddy = _strip_caddy_comments(caddy_full)
|
|
||||||
assert ":80 {" in caddy
|
assert ":80 {" in caddy
|
||||||
assert "tls internal" not in caddy
|
assert "__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {" in caddy
|
||||||
assert "__FURTKA_HOSTNAME__" not in caddy
|
assert "tls internal" in caddy
|
||||||
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in caddy
|
# Shared routes live in a named snippet to avoid drift between the two
|
||||||
# Shared routes still live in a named snippet so the HTTPS toggle's
|
# listeners — both site blocks must import it.
|
||||||
# snippet can import the same routes without duplication.
|
|
||||||
assert "(furtka_routes)" in caddy
|
assert "(furtka_routes)" in caddy
|
||||||
# Default Caddyfile imports it once (inside :80). The HTTPS snippet,
|
assert caddy.count("import furtka_routes") == 2
|
||||||
# when written by the toggle, imports it a second time.
|
|
||||||
assert caddy.count("import furtka_routes") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_caddyfile_disables_caddy_auto_redirects():
|
def test_caddyfile_disables_caddy_auto_redirects():
|
||||||
|
|
@ -187,28 +167,16 @@ def test_caddyfile_exposes_root_ca_download():
|
||||||
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
assert "attachment; filename=furtka-local-rootCA.crt" in caddy
|
||||||
|
|
||||||
|
|
||||||
def test_post_install_writes_caddyfile_without_hostname_placeholder(install_cmds):
|
def test_post_install_substitutes_hostname_in_caddyfile(install_cmds):
|
||||||
# 26.15-alpha: the shipped Caddyfile no longer carries the
|
# Fresh installs: the placeholder the asset ships with must be replaced
|
||||||
# __FURTKA_HOSTNAME__ marker — HTTPS + hostname now live in the
|
# with the hostname the user picked in the form. The `testhost` value
|
||||||
# opt-in snippet written by set_force_https(), not in the base
|
# comes from the install_cmds fixture. Without substitution Caddy's
|
||||||
# Caddyfile. Verify the post-install writes the file as-is (no
|
# `tls internal` never issues a leaf cert for the real hostname.
|
||||||
# substitution expected) and it has the opt-in import glob.
|
|
||||||
caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None)
|
caddyfile_cmd = next((c for c in install_cmds if " > /etc/caddy/Caddyfile" in c), None)
|
||||||
assert caddyfile_cmd is not None
|
assert caddyfile_cmd is not None
|
||||||
written_full = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
written = _extract_written_content(caddyfile_cmd, "/etc/caddy/Caddyfile")
|
||||||
written = _strip_caddy_comments(written_full)
|
|
||||||
assert "__FURTKA_HOSTNAME__" not in written
|
assert "__FURTKA_HOSTNAME__" not in written
|
||||||
assert "import /etc/caddy/furtka-https.d/*.caddyfile" in written
|
assert "testhost.local, testhost {" in written
|
||||||
assert "tls internal" not in written
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_install_creates_https_snippet_dir(install_cmds):
|
|
||||||
# The top-level HTTPS opt-in snippet dir must exist before Caddy's
|
|
||||||
# first start — its glob import tolerates an empty directory, but
|
|
||||||
# not a missing one on older Caddy builds. Parallel guarantee to
|
|
||||||
# test_post_install_creates_furtka_d_snippet_dir below.
|
|
||||||
matching = [c for c in install_cmds if "/etc/caddy/furtka-https.d" in c and "install -d" in c]
|
|
||||||
assert matching, "no install -d command creates /etc/caddy/furtka-https.d"
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
def test_post_install_creates_furtka_d_snippet_dir(install_cmds):
|
||||||
|
|
@ -227,25 +195,6 @@ 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")
|
||||||
|
|
@ -278,12 +227,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -97,16 +96,6 @@ 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
|
||||||
|
|
@ -121,10 +110,6 @@ 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"
|
||||||
|
|
||||||
|
|
@ -279,9 +264,6 @@ _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",
|
||||||
|
|
@ -413,14 +395,6 @@ def _post_install_commands(hostname, admin_username, admin_password):
|
||||||
# an empty dir but not a missing one on every Caddy version, so we
|
# an empty dir but not a missing one on every Caddy version, so we
|
||||||
# create it up front and stay on the safe side.
|
# create it up front and stay on the safe side.
|
||||||
"install -d -m 0755 -o root -g root /etc/caddy/furtka.d",
|
"install -d -m 0755 -o root -g root /etc/caddy/furtka.d",
|
||||||
# Parallel dir for the top-level HTTPS-listener snippet, written
|
|
||||||
# by /api/furtka/https/force (26.15-alpha+) when the user opts
|
|
||||||
# into HTTPS. Empty by default so fresh installs never generate
|
|
||||||
# a tls internal cert — that was the 26.14 regression where
|
|
||||||
# Firefox hit unbypassable SEC_ERROR_BAD_SIGNATURE because
|
|
||||||
# Caddy's fixed intermediate-CN clashed with any cached trust
|
|
||||||
# from a previously-reinstalled Furtka box.
|
|
||||||
"install -d -m 0755 -o root -g root /etc/caddy/furtka-https.d",
|
|
||||||
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
||||||
# (systemd unit points there). Content comes from the shipped asset,
|
# (systemd unit points there). Content comes from the shipped asset,
|
||||||
# which we copy in at install time so updates that change routing
|
# which we copy in at install time so updates that change routing
|
||||||
|
|
@ -496,7 +470,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.timer + furtka-status.timer
|
# custom_commands, so our own furtka-welcome + 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",
|
||||||
|
|
@ -552,9 +526,7 @@ 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):
|
||||||
# Append: install_run() already truncated the log and may have written
|
log_fh = open(log_path, "wb")
|
||||||
# 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",
|
||||||
|
|
@ -626,11 +598,6 @@ 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"))
|
||||||
|
|
||||||
|
|
@ -642,7 +609,6 @@ 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"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,41 +1,6 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def _boot_disk_name():
|
|
||||||
"""Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None.
|
|
||||||
|
|
||||||
On a normal box `/run/archiso/bootmnt` does not exist and we return None,
|
|
||||||
leaving the device list untouched. On bare metal booted from USB this is
|
|
||||||
the stick we booted from — we want to filter it out so the user can't
|
|
||||||
accidentally pick it as the install target.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
partition = result.stdout.strip()
|
|
||||||
if not partition:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parent = subprocess.run(
|
|
||||||
["lsblk", "-no", "PKNAME", partition],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
if parent.returncode != 0:
|
|
||||||
return None
|
|
||||||
name = parent.stdout.strip().splitlines()[0] if parent.stdout.strip() else ""
|
|
||||||
return name or None
|
|
||||||
|
|
||||||
|
|
||||||
def _smart_status(device):
|
def _smart_status(device):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -110,14 +75,11 @@ def score_device(device, size_gb):
|
||||||
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
|
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
|
||||||
|
|
||||||
|
|
||||||
def parse_lsblk_output(output, boot_disk=None):
|
def parse_lsblk_output(output):
|
||||||
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
|
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
|
||||||
|
|
||||||
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
|
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
|
||||||
CD-ROM (rom) don't show up as install targets. If `boot_disk` is given,
|
CD-ROM (rom) don't show up as install targets.
|
||||||
that disk is also dropped — it's the USB stick the live ISO booted from
|
|
||||||
on bare metal, where it appears as TYPE=disk and would otherwise be a
|
|
||||||
valid-looking install target.
|
|
||||||
"""
|
"""
|
||||||
devices = []
|
devices = []
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
|
|
@ -129,8 +91,6 @@ def parse_lsblk_output(output, boot_disk=None):
|
||||||
name, size, dev_type = parts[0], parts[1], parts[2]
|
name, size, dev_type = parts[0], parts[1], parts[2]
|
||||||
if dev_type != "disk":
|
if dev_type != "disk":
|
||||||
continue
|
continue
|
||||||
if boot_disk and name == boot_disk:
|
|
||||||
continue
|
|
||||||
device = f"/dev/{name}"
|
device = f"/dev/{name}"
|
||||||
size_gb = parse_size_gb(size)
|
size_gb = parse_size_gb(size)
|
||||||
status = _smart_status(device)
|
status = _smart_status(device)
|
||||||
|
|
@ -160,7 +120,7 @@ def list_scored_devices():
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"Error listing devices: {e}")
|
print(f"Error listing devices: {e}")
|
||||||
return []
|
return []
|
||||||
return parse_lsblk_output(result.stdout, boot_disk=_boot_disk_name())
|
return parse_lsblk_output(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -421,11 +421,3 @@ 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; }
|
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,16 @@
|
||||||
{% 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. Two more steps and Furtka is running from its own disk.
|
{% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
|
||||||
{% 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('USB stick removed? Click OK to restart the computer.');">
|
onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-primary">USB stick is out — restart now</button>
|
<button type="submit" class="btn btn-primary">Reboot now</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,6 @@ 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:
|
||||||
|
|
@ -67,18 +58,11 @@ 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, mark-m / mark-r (poll)
|
partials/ head, header, footer, gate SVG, lang switcher
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@
|
||||||
--accent: #c03a28;
|
--accent: #c03a28;
|
||||||
--accent-hover: #a0301f;
|
--accent-hover: #a0301f;
|
||||||
--border: #e4e3dc;
|
--border: #e4e3dc;
|
||||||
--accent-glow: rgba(192, 58, 40, 0.2);
|
|
||||||
--card-bg: rgba(247, 246, 243, 0.72);
|
|
||||||
--card-border: var(--border);
|
|
||||||
--scene-opacity: 0.18;
|
|
||||||
--font-sans:
|
--font-sans:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
Arial, "Noto Sans", sans-serif;
|
Arial, "Noto Sans", sans-serif;
|
||||||
|
|
@ -27,10 +23,6 @@
|
||||||
--accent: #ff6b56;
|
--accent: #ff6b56;
|
||||||
--accent-hover: #ff8b78;
|
--accent-hover: #ff8b78;
|
||||||
--border: #232326;
|
--border: #232326;
|
||||||
--accent-glow: rgba(255, 107, 86, 0.4);
|
|
||||||
--card-bg: rgba(23, 23, 26, 0.65);
|
|
||||||
--card-border: #26262b;
|
|
||||||
--scene-opacity: 0.34;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,25 +43,6 @@ body {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Animated background canvas (home only) ─────────────── */
|
|
||||||
|
|
||||||
.scene-canvas {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-header,
|
|
||||||
main.container,
|
|
||||||
.site-footer {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|
@ -198,36 +171,11 @@ main.container {
|
||||||
.home h1 {
|
.home h1 {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: clamp(3.5rem, 14vw, 11rem);
|
font-size: clamp(3.25rem, 10vw, 6.5rem);
|
||||||
line-height: 0.9;
|
line-height: 0.95;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.035em;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background-image: linear-gradient(180deg, var(--fg) 0%, var(--accent) 110%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.home h1 {
|
|
||||||
filter: drop-shadow(0 0 28px var(--accent-glow));
|
|
||||||
}
|
|
||||||
.home .lede {
|
|
||||||
color: #c8c8cc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
min-height: 78vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding-block: 4.5rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home .lede {
|
|
||||||
font-weight: 450;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home .lede {
|
.home .lede {
|
||||||
|
|
@ -310,261 +258,3 @@ main.container {
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Primary CTA ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.cta-row { margin-top: 2.5rem; }
|
|
||||||
|
|
||||||
.cta {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
padding: 1.1rem 2rem;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.02rem;
|
|
||||||
letter-spacing: 0.005em;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
transition: transform 180ms, box-shadow 180ms, background 180ms, color 180ms;
|
|
||||||
}
|
|
||||||
.cta--primary {
|
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 10px 36px var(--accent-glow),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
|
|
||||||
animation: cta-pulse 2.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.cta--primary:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 18px 52px var(--accent-glow),
|
|
||||||
0 0 0 1px var(--accent);
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
.cta--primary:active { transform: translateY(-1px); }
|
|
||||||
.cta--primary span { transition: transform 180ms; }
|
|
||||||
.cta--primary:hover span { transform: translateX(4px); }
|
|
||||||
|
|
||||||
@keyframes cta-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 10px 36px var(--accent-glow),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); }
|
|
||||||
50% { box-shadow: 0 14px 48px var(--accent-glow),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.cta--primary { animation: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Intro paragraph (home, between hero and feature grids) ─ */
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
max-width: 38rem;
|
|
||||||
margin: 0 0 4rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.intro p { margin: 0 0 1rem; }
|
|
||||||
.intro p:last-child { margin: 0; }
|
|
||||||
.intro strong { font-weight: 600; }
|
|
||||||
|
|
||||||
/* ── Feature sections (home) ─────────────────────────────── */
|
|
||||||
|
|
||||||
.feature-section { margin-block: 4rem; }
|
|
||||||
|
|
||||||
.section-eyebrow {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.5rem 1.5rem 1.4rem;
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
transition: transform 240ms, border-color 240ms, box-shadow 240ms;
|
|
||||||
}
|
|
||||||
.feature-card:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 10px 32px var(--accent-glow);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.feature-card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.feature-card strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Closer prose (home, after feature grids) ────────────── */
|
|
||||||
|
|
||||||
.closer {
|
|
||||||
margin-top: 4rem;
|
|
||||||
max-width: var(--measure);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reveal-on-load (hero) and reveal-on-scroll (cards) ──── */
|
|
||||||
|
|
||||||
.js .reveal,
|
|
||||||
.js [data-gsap="card"] {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(40px);
|
|
||||||
will-change: opacity, transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.scene-canvas { display: none; }
|
|
||||||
.js .reveal,
|
|
||||||
.js [data-gsap="card"] {
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: none !important;
|
|
||||||
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; } }
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
(function () {
|
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
||||||
if (!window.gsap || !window.ScrollTrigger || !window.Lenis) return;
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
|
|
||||||
lenis.on('scroll', ScrollTrigger.update);
|
|
||||||
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
|
|
||||||
gsap.ticker.lagSmoothing(0);
|
|
||||||
|
|
||||||
// Hero stagger — runs once on load.
|
|
||||||
gsap.to('.hero .reveal', {
|
|
||||||
y: 0, opacity: 1, duration: 1.1, ease: 'power3.out', stagger: 0.12
|
|
||||||
});
|
|
||||||
|
|
||||||
// Card reveals — batched so cards in the same row come in together.
|
|
||||||
ScrollTrigger.batch('[data-gsap="card"]', {
|
|
||||||
start: 'top 90%',
|
|
||||||
onEnter: (els) => gsap.to(els, {
|
|
||||||
y: 0, opacity: 1, scale: 1,
|
|
||||||
duration: 0.9, ease: 'power3.out', stagger: 0.08, overwrite: true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
// 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();
|
|
||||||
})();
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
(function () {
|
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
||||||
if (!window.WebGLRenderingContext || !window.THREE) return;
|
|
||||||
|
|
||||||
const canvas = document.getElementById('scene');
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
const readVar = (name) => getComputedStyle(root).getPropertyValue(name).trim();
|
|
||||||
const readOpacity = () => parseFloat(readVar('--scene-opacity')) || 0.18;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
60, window.innerWidth / window.innerHeight, 0.1, 100
|
|
||||||
);
|
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
||||||
|
|
||||||
const geometry = new THREE.TorusKnotGeometry(2.5, 0.4, 130, 20);
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
|
||||||
color: readVar('--accent') || '#c03a28',
|
|
||||||
wireframe: true,
|
|
||||||
transparent: true,
|
|
||||||
opacity: readOpacity()
|
|
||||||
});
|
|
||||||
const core = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(core);
|
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
||||||
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
||||||
dir.position.set(5, 5, 5);
|
|
||||||
scene.add(dir);
|
|
||||||
|
|
||||||
const BASE_Z = 9;
|
|
||||||
camera.position.z = BASE_Z;
|
|
||||||
|
|
||||||
let scrollY = window.scrollY || 0;
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
scrollY = window.scrollY || 0;
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
let baseOpacity = readOpacity();
|
|
||||||
|
|
||||||
let running = true;
|
|
||||||
function tick() {
|
|
||||||
if (!running) return;
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
// Continuous slow drift.
|
|
||||||
core.rotation.y += 0.0015;
|
|
||||||
core.rotation.z += 0.0006;
|
|
||||||
|
|
||||||
// Scroll-driven motion: zoom in, scale up, tilt.
|
|
||||||
const s = Math.min(scrollY, 2000);
|
|
||||||
camera.position.z = BASE_Z - s * 0.0022;
|
|
||||||
const scale = 1 + s * 0.00035;
|
|
||||||
core.scale.set(scale, scale, scale);
|
|
||||||
core.rotation.x = s * 0.0008;
|
|
||||||
|
|
||||||
// Fade past hero so feature cards stay readable.
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const fadeStart = vh * 0.5;
|
|
||||||
const fadeEnd = vh * 1.4;
|
|
||||||
const t = Math.max(0, Math.min(1, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
|
|
||||||
material.opacity = baseOpacity * (1 - t * 0.92);
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
tick();
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
running = false;
|
|
||||||
} else if (!running) {
|
|
||||||
running = true;
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const updateTheme = () => {
|
|
||||||
const accent = readVar('--accent');
|
|
||||||
if (accent) material.color.set(accent);
|
|
||||||
baseOpacity = readOpacity();
|
|
||||||
};
|
|
||||||
if (mql.addEventListener) {
|
|
||||||
mql.addEventListener('change', updateTheme);
|
|
||||||
} else if (mql.addListener) {
|
|
||||||
mql.addListener(updateTheme);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
19
website/assets/js/vendor/PROVENANCE.md
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
# Vendored JavaScript libraries
|
|
||||||
|
|
||||||
These minified bundles are checked into the repo so furtka.org has zero
|
|
||||||
third-party-CDN dependencies at runtime. Pin date: **2026-04-27**.
|
|
||||||
|
|
||||||
| File | Version | Source |
|
|
||||||
|---|---|---|
|
|
||||||
| `three.min.js` | r128 | https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js |
|
|
||||||
| `gsap.min.js` | 3.12.2 (core only) | https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js |
|
|
||||||
| `ScrollTrigger.min.js` | 3.12.2 | https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js |
|
|
||||||
| `lenis.min.js` | @studio-freight/lenis 1.0.33 | https://unpkg.com/@studio-freight/lenis@1.0.33/dist/lenis.min.js |
|
|
||||||
|
|
||||||
All four expose UMD globals (`THREE`, `gsap`, `ScrollTrigger`, `Lenis`).
|
|
||||||
None are ES modules, so no `js.Build` step is needed — Hugo just fingerprints them.
|
|
||||||
|
|
||||||
GSAP "Club" plugins (SplitText, MorphSVG, etc.) are **not** free for commercial use.
|
|
||||||
Only `gsap` core + `ScrollTrigger` (both standard MIT-style license) are bundled.
|
|
||||||
|
|
||||||
To refresh: re-run `curl -sSfL -o <file> <url>` and bump the pin date here.
|
|
||||||
11
website/assets/js/vendor/ScrollTrigger.min.js
vendored
11
website/assets/js/vendor/gsap.min.js
vendored
1
website/assets/js/vendor/lenis.min.js
vendored
6
website/assets/js/vendor/three.min.js
vendored
|
|
@ -1,39 +1,33 @@
|
||||||
---
|
---
|
||||||
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.20-alpha</span> — in Arbeit"
|
status: "<span class=\"mono\">26.8-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.
|
|
||||||
intro: |
|
|
||||||
**Furtka** ist ein offenes Heimserver-Betriebssystem.
|
|
||||||
USB-Stick einstecken, durch einen Assistenten klicken, und aus jedem
|
|
||||||
alten Rechner wird eine private Cloud für den Haushalt — mit eigenen
|
|
||||||
Apps, eigenem Namen im Netz, eigenen Daten.
|
|
||||||
|
|
||||||
Das Ziel ist einfach: **dein Vater soll das einrichten können.**
|
|
||||||
features_today_label: "Was heute schon geht"
|
|
||||||
features_today:
|
|
||||||
- "Vom USB-Stick booten und Furtka auf die Festplatte einrichten"
|
|
||||||
- "Ein Assistent fragt nach Name, Benutzer und Netzwerk — fertig"
|
|
||||||
- "Danach: Bedienseite im Browser öffnen"
|
|
||||||
- "Erste App: **Dateifreigabe im Heimnetz** (alle im WLAN sehen den Ordner)"
|
|
||||||
- "Apps mit einem Klick installieren und entfernen"
|
|
||||||
- "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"
|
|
||||||
- "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:
|
|
||||||
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
|
|
||||||
- "Einfachere Sprache im Einrichtungs-Assistenten"
|
|
||||||
- "Den Server auch von unterwegs erreichen"
|
|
||||||
- "Mehrere Server zusammenschalten"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Furtka** ist ein offenes Heimserver-Betriebssystem.
|
||||||
|
USB-Stick einstecken, durch einen Assistenten klicken, und aus jedem
|
||||||
|
alten Rechner wird eine private Cloud für den Haushalt — mit eigenen
|
||||||
|
Apps, eigenem Namen im Netz, eigenen Daten.
|
||||||
|
|
||||||
|
Das Ziel ist einfach: **dein Vater soll das einrichten können.**
|
||||||
|
|
||||||
|
### Was als Nächstes kommt
|
||||||
|
- Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien
|
||||||
|
- Einfachere Sprache im Einrichtungs-Assistenten
|
||||||
|
- Sichere Verbindung im Heimnetz (ohne Warnmeldung im Browser)
|
||||||
|
- Mehrere Server zusammenschalten
|
||||||
|
|
||||||
|
### Was heute schon geht
|
||||||
|
- Vom USB-Stick booten und Furtka auf die Festplatte einrichten
|
||||||
|
- Ein Assistent fragt nach Name, Benutzer und Netzwerk — fertig
|
||||||
|
- Danach: Bedienseite im Browser öffnen
|
||||||
|
- Erste App: **Dateifreigabe im Heimnetz** (alle im WLAN sehen den Ordner)
|
||||||
|
- Apps mit einem Klick installieren und entfernen
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
Wir sind zu zweit und bauen das öffentlich, abends und am Wochenende.
|
Wir sind zu zweit und bauen das öffentlich, abends und am Wochenende.
|
||||||
Es ist früh.
|
Es ist früh.
|
||||||
|
|
||||||
Mitlesen? Schreib an <hallo@furtka.org>.
|
Mitlesen? Schreib an <a href="mailto:hallo@furtka.org">hallo@furtka.org</a>.
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,33 @@
|
||||||
---
|
---
|
||||||
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.20-alpha</span> — work in progress"
|
status: "<span class=\"mono\">26.8-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.
|
|
||||||
intro: |
|
|
||||||
**Furtka** is an open-source home server OS.
|
|
||||||
Boot from USB, click through a wizard, and any old computer
|
|
||||||
turns into a private cloud for your household — with your own apps,
|
|
||||||
your own name on the network, your own data.
|
|
||||||
|
|
||||||
The goal is simple: **your dad should be able to set this up.**
|
|
||||||
features_today_label: "What works today"
|
|
||||||
features_today:
|
|
||||||
- "Boot from USB stick and install Furtka onto the hard drive"
|
|
||||||
- "A wizard asks for name, user and network — done"
|
|
||||||
- "Then: open the control page in your browser"
|
|
||||||
- "First app: **file sharing on the home network** (everyone on Wi-Fi sees the folder)"
|
|
||||||
- "Install and remove apps with one click"
|
|
||||||
- "Update an installed app with one click (pulls the newest container image)"
|
|
||||||
- "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:
|
|
||||||
- "Apps for photos, files, smart home, game streaming and media"
|
|
||||||
- "Plainer language in the setup wizard"
|
|
||||||
- "Reaching your server from outside the house"
|
|
||||||
- "Linking several servers together"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Furtka** is an open-source home server OS.
|
||||||
|
Boot from USB, click through a wizard, and any old computer
|
||||||
|
turns into a private cloud for your household — with your own apps,
|
||||||
|
your own name on the network, your own data.
|
||||||
|
|
||||||
|
The goal is simple: **your dad should be able to set this up.**
|
||||||
|
|
||||||
|
### What's coming next
|
||||||
|
- Apps for photos, files, smart home, game streaming and media
|
||||||
|
- Plainer language in the setup wizard
|
||||||
|
- Secure connection on your home network (no browser warning)
|
||||||
|
- Linking several servers together
|
||||||
|
|
||||||
|
### What works today
|
||||||
|
- Boot from USB stick and install Furtka onto the hard drive
|
||||||
|
- A wizard asks for name, user and network — done
|
||||||
|
- Then: open the control page in your browser
|
||||||
|
- First app: **file sharing on the home network** (everyone on Wi-Fi sees the folder)
|
||||||
|
- Install and remove apps with one click
|
||||||
|
- Update an installed app with one click (pulls the newest container image)
|
||||||
|
- Update Furtka itself with one click — no reinstalling for new features
|
||||||
|
|
||||||
|
|
||||||
We're two people building it in public on evenings and weekends.
|
We're two people building it in public on evenings and weekends.
|
||||||
It's early.
|
It's early.
|
||||||
|
|
||||||
Want to follow along? Write to <hallo@furtka.org>.
|
Want to follow along? Write to <a href="mailto:hallo@furtka.org">hallo@furtka.org</a>.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ 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 — und, solange die
|
ausschließlich kurzfristige Server-Zugriffsprotokolle.
|
||||||
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
|
|
||||||
|
|
||||||
### Verantwortlicher
|
### Verantwortlicher
|
||||||
|
|
||||||
|
|
@ -44,22 +43,6 @@ 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
|
||||||
|
|
@ -92,5 +75,5 @@ Website: <https://www.datenschutz.rlp.de>
|
||||||
|
|
||||||
### Stand
|
### Stand
|
||||||
|
|
||||||
Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
|
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026
|
||||||
aktualisiert.
|
aktualisiert.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ 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 — plus, while
|
technical data collected is short-lived server access logs.
|
||||||
the [logo poll](/logo/) is running, one salted hash per vote (see below).
|
|
||||||
|
|
||||||
### Controller
|
### Controller
|
||||||
|
|
||||||
|
|
@ -44,20 +43,6 @@ 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
|
||||||
|
|
@ -90,6 +75,6 @@ Website: <https://www.datenschutz.rlp.de>
|
||||||
|
|
||||||
### Last updated
|
### Last updated
|
||||||
|
|
||||||
This statement was last updated on 2026-08-26.
|
This statement was last updated on 2026-04-18.
|
||||||
|
|
||||||
The German version of this privacy statement is the legally binding one.
|
The German version of this privacy statement is the legally binding one.
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -11,13 +11,6 @@ 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' \
|
||||||
|
|
|
||||||
|
|
@ -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.20-alpha"
|
version = "26.8-alpha"
|
||||||
contactEmail = "hallo@furtka.org"
|
contactEmail = "hallo@furtka.org"
|
||||||
|
|
||||||
[markup.goldmark.renderer]
|
[markup.goldmark.renderer]
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ .Site.Language.Lang }}" class="no-js">
|
<html lang="{{ .Site.Language.Lang }}">
|
||||||
<head>
|
<head>
|
||||||
{{ partial "head.html" . }}
|
{{ partial "head.html" . }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{ if .IsHome }}<canvas id="scene" class="scene-canvas" aria-hidden="true"></canvas>{{ end }}
|
|
||||||
{{ partial "header.html" . }}
|
{{ partial "header.html" . }}
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{{ block "main" . }}{{ end }}
|
{{ block "main" . }}{{ end }}
|
||||||
</main>
|
</main>
|
||||||
{{ partial "footer.html" . }}
|
{{ partial "footer.html" . }}
|
||||||
{{ if .IsHome }}{{ partial "scripts.html" . }}{{ end }}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,13 @@
|
||||||
<article class="home">
|
<article class="home">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
{{ with .Params.status }}
|
{{ with .Params.status }}
|
||||||
<p class="status-chip reveal">{{ . | safeHTML }}</p>
|
<p class="status-chip">{{ . | safeHTML }}</p>
|
||||||
{{ end }}
|
|
||||||
<h1 class="reveal">{{ .Title }}</h1>
|
|
||||||
{{ with site.Params.description }}<p class="lede reveal">{{ . }}</p>{{ end }}
|
|
||||||
<p class="cta-row reveal">
|
|
||||||
<a class="cta cta--primary" href="https://forgejo.sourcegate.online/daniel/furtka/releases">
|
|
||||||
{{ if eq site.Language.Lang "de" }}Neuestes Release{{ else }}Latest release{{ end }}
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</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 }}
|
{{ end }}
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ with site.Params.description }}<p class="lede">{{ . }}</p>{{ end }}
|
||||||
</header>
|
</header>
|
||||||
|
<div class="prose">
|
||||||
{{ with .Params.intro }}
|
{{ .Content }}
|
||||||
<section class="intro">{{ . | markdownify }}</section>
|
</div>
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if .Params.features_today }}
|
|
||||||
<section class="feature-section">
|
|
||||||
{{ with .Params.features_today_label }}<p class="section-eyebrow">{{ . }}</p>{{ end }}
|
|
||||||
<div class="feature-grid">
|
|
||||||
{{ range .Params.features_today }}
|
|
||||||
<article class="feature-card" data-gsap="card">{{ . | markdownify }}</article>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if .Params.features_next }}
|
|
||||||
<section class="feature-section">
|
|
||||||
{{ with .Params.features_next_label }}<p class="section-eyebrow">{{ . }}</p>{{ end }}
|
|
||||||
<div class="feature-grid">
|
|
||||||
{{ range .Params.features_next }}
|
|
||||||
<article class="feature-card" data-gsap="card">{{ . | markdownify }}</article>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ with .Content }}
|
|
||||||
<section class="prose closer">{{ . }}</section>
|
|
||||||
{{ end }}
|
|
||||||
</article>
|
</article>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,13 @@
|
||||||
<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>
|
<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="#0d0d0f" media="(prefers-color-scheme: dark)">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<meta property="og:site_name" content="{{ site.Title }}">
|
<meta property="og:site_name" content="{{ site.Title }}">
|
||||||
<meta property="og:title" content="{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}">
|
<meta property="og:title" content="{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}">
|
||||||
<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 }}">
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
{{- /* 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>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
{{- /* 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>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{{ $three := resources.Get "js/vendor/three.min.js" | fingerprint }}
|
|
||||||
{{ $gsap := resources.Get "js/vendor/gsap.min.js" | fingerprint }}
|
|
||||||
{{ $st := resources.Get "js/vendor/ScrollTrigger.min.js" | fingerprint }}
|
|
||||||
{{ $lenis := resources.Get "js/vendor/lenis.min.js" | fingerprint }}
|
|
||||||
{{ $scene := resources.Get "js/scene.js" | fingerprint }}
|
|
||||||
{{ $anim := resources.Get "js/animations.js" | fingerprint }}
|
|
||||||
<script defer src="{{ $three.RelPermalink }}" integrity="{{ $three.Data.Integrity }}"></script>
|
|
||||||
<script defer src="{{ $gsap.RelPermalink }}" integrity="{{ $gsap.Data.Integrity }}"></script>
|
|
||||||
<script defer src="{{ $st.RelPermalink }}" integrity="{{ $st.Data.Integrity }}"></script>
|
|
||||||
<script defer src="{{ $lenis.RelPermalink }}" integrity="{{ $lenis.Data.Integrity }}"></script>
|
|
||||||
<script defer src="{{ $scene.RelPermalink }}" integrity="{{ $scene.Data.Integrity }}"></script>
|
|
||||||
<script defer src="{{ $anim.RelPermalink }}" integrity="{{ $anim.Data.Integrity }}"></script>
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
{{ 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 }}
|
|
||||||
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -1,9 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
#!/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())
|
|
||||||