fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking
Five issues surfaced by the Phase-2 audit before the next ISO rebuild:
P1 (real blockers for a fresh install / self-update):
1. chmod +x furtka/assets/bin/furtka-status, furtka-welcome. They were
mode 644 in git, so the tarball shipped them non-executable and every
ExecStart referencing /opt/furtka/current/assets/bin/furtka-* would
have failed on first boot with Permission denied.
2. apply_update now refreshes /etc/caddy/Caddyfile from the new version
when the content differs, then reloads caddy. Without this, a release
that changes Caddy routes silently stays on the old config.
3. apply_update now systemctl-links any new unit files shipped by the
update, not just the five linked at install time. A future release
that adds furtka-foo.service would otherwise never appear in
/etc/systemd/system/.
P2 (hardening, not blockers today):
4. _resource_manager_commands now aborts the install if the tarball's
VERSION file is empty — otherwise `mv "$staging" /opt/furtka/versions/`
would move the staging dir in as a subdirectory and the symlink
target would be invalid.
5. _extract_tarball passes filter='data' to tarfile.extractall on
Python 3.12+ to catch symlink-escape / setuid / device-node tricks
that the regex path-check can't see. Falls back silently on older
interpreters.
Plus the CHANGELOG [Unreleased] section got filled in with the whole
Phase-1 + Phase-2 + UI-uplevel body so a 26.1-alpha tag cut off main
has meaningful release notes.
Test additions / updates:
- test_refresh_caddyfile_{copies_when_different,noops_if_source_missing}
- test_link_new_units_only_links_missing
- test_extract_tarball_uses_data_filter_when_available
- test_apply_update_happy_path now verifies the Caddyfile gets copied.
- test_resource_manager_extracts_to_versioned_slot verifies the
empty-VERSION guard is present in the install command.
Paths now overridable via FURTKA_CADDYFILE_PATH + FURTKA_SYSTEMD_DIR so
tests can pin a tmpdir for these new fs operations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c58eade1c
commit
b8fdb62b41
7 changed files with 164 additions and 8 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -9,24 +9,30 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Furtka self-update** (Phase 2). Tagging a release on main fires `.forgejo/workflows/release.yml`, which packages `furtka/` + `apps/` + a root-level `VERSION` file as `furtka-<tag>.tar.gz`, uploads it plus a `.sha256` + `release.json` to the Forgejo releases page, and makes the release available to running boxes. New CLI: `furtka update [--check]` + `furtka rollback`. New endpoints: `POST /api/furtka/update/check` + `/apply` + `GET /api/furtka/update/status`. UI: "Furtka updates" card on `/settings` shows installed vs latest, Update button runs the apply flow detached via `systemd-run`, progress polls `/update-state.json` served by Caddy so the mid-update API restart doesn't interrupt reporting. Atomic `/opt/furtka/current` symlink flip, auto-rollback on health-check failure post-restart, SHA256-verified downloads.
|
||||||
|
- **Per-app container image updates** (Phase 1). `POST /api/apps/<name>/update` runs `docker compose pull`, compares the running container's image digest to the just-pulled local image digest per service, and only restarts containers whose image actually changed. Update button on each installed-app row in `/apps`. Keeps `image: :latest` pins simple — no compose-file mutations.
|
||||||
|
- **Per-version install layout** on `/opt/furtka/`. Install now extracts the resource-manager payload to `/opt/furtka/versions/<VERSION>/` and creates `/opt/furtka/current` as an atomic symlink; updates flip the symlink in place and `systemctl link` every unit from the shipped `assets/systemd/` tree. Runtime JSON (`status.json`, `furtka.json`, `update-state.json`) moved to `/var/lib/furtka/` so self-updates never clobber it.
|
||||||
|
- **On-box UI uplevel** across three pages sharing one design system (`/style.css` served by Caddy). Redesigned landing page with a "Your apps" tile grid driven by `/api/apps`, a `fileshare` app tile that deep-links to `smb://<host>.local/files`, status tiles, and subtle "Coming next" links to `furtka.org`. `/apps` page renders real app icons inlined from each manifest's `icon.svg` (defensive SVG sanitiser — strips script/on*/javascript: content, 16 KB cap). New `/settings` page with About-this-box, Appearance, Furtka-updates, and Coming-next sections. Persistent top nav (Jakob's Law) on every page. Light-mode support via `prefers-color-scheme`.
|
||||||
|
- **Webinstaller step 2 (boot drive)** now shows size / type / health chips plus a "Recommended" badge on the auto-selected drive instead of a raw numeric score.
|
||||||
|
- **Forgejo branch protection on `main`** — no direct pushes except owner-whitelisted, required status checks (`CI / lint*`, `CI / test*`, `CI / validate-json*`), applied via the idempotent `ops/forgejo/apply-branch-protection.sh` script.
|
||||||
|
|
||||||
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow a "Settings" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
|
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow a "Settings" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
|
||||||
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
|
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
|
||||||
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
|
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
|
||||||
|
- **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`.
|
||||||
|
- **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 1–3 respond at `http://<vm-ip>:5000`.
|
||||||
|
- **Public website at [furtka.org](https://furtka.org)** (`website/`). Hugo static site, English + German, served from `/var/www/furtka.org` on `forge-runner-01` via nginx. Upstream openresty proxy handles TLS. Intentionally minimal single-page copy while the project is pre-alpha. Deploy is `./website/deploy.sh` (rsync + remote Hugo build); one-time VM setup in `ops/nginx/setup-vm.sh`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Every on-box asset (landing page, settings page, style.css, status/welcome scripts, systemd units, Caddyfile) moved from inline Python string constants in `webinstaller/app.py` into real files under `furtka/assets/`. The installer reads them from disk at install time; the self-updater ships them in the release tarball.
|
||||||
|
- Settings-button label went from "Einstellungen" (prototyping leftover) to "Settings" — rest of the UI chrome is English.
|
||||||
- Keyboard layout at the TTY now follows the chosen installer language (`de` → `de`, `pl` → `pl`, `en` → `us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
|
- Keyboard layout at the TTY now follows the chosen installer language (`de` → `de`, `pl` → `pl`, `en` → `us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
|
||||||
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
|
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
|
||||||
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
|
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
|
||||||
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
|
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
|
||||||
- Python package renamed from `homebase` → `furtka` in `pyproject.toml`.
|
- Python package renamed from `homebase` → `furtka` in `pyproject.toml`.
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`.
|
|
||||||
- **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 1–3 respond at `http://<vm-ip>:5000`.
|
|
||||||
- **Public website at [furtka.org](https://furtka.org)** (`website/`). Hugo static site, English + German, served from `/var/www/furtka.org` on `forge-runner-01` via nginx. Upstream openresty proxy handles TLS. Intentionally minimal single-page copy while the project is pre-alpha. Deploy is `./website/deploy.sh` (rsync + remote Hugo build); one-time VM setup in `ops/nginx/setup-vm.sh`.
|
|
||||||
|
|
||||||
## [26.0-alpha] - 2026-04-13
|
## [26.0-alpha] - 2026-04-13
|
||||||
|
|
||||||
First tagged snapshot. Pre-alpha — the installer does not yet boot, but the design is locked and the prototype components are shaped.
|
First tagged snapshot. Pre-alpha — the installer does not yet boot, but the design is locked and the prototype components are shaped.
|
||||||
|
|
|
||||||
0
furtka/assets/bin/furtka-status
Normal file → Executable file
0
furtka/assets/bin/furtka-status
Normal file → Executable file
0
furtka/assets/bin/furtka-welcome
Normal file → Executable file
0
furtka/assets/bin/furtka-welcome
Normal file → Executable file
|
|
@ -45,6 +45,8 @@ FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online"
|
||||||
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
|
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
|
||||||
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
|
||||||
_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka"))
|
_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka"))
|
||||||
|
_CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddyfile"))
|
||||||
|
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
|
||||||
|
|
||||||
|
|
||||||
class UpdateError(RuntimeError):
|
class UpdateError(RuntimeError):
|
||||||
|
|
@ -187,6 +189,13 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
|
||||||
for member in tf.getmembers():
|
for member in tf.getmembers():
|
||||||
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
|
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
|
||||||
raise UpdateError(f"refusing tarball entry {member.name!r}")
|
raise UpdateError(f"refusing tarball entry {member.name!r}")
|
||||||
|
# Python 3.12+ grew a stricter default filter; opt into it where
|
||||||
|
# available to catch symlink-escape / device-node / setuid tricks
|
||||||
|
# that our regex check can't see. Older Pythons fall back to the
|
||||||
|
# historical permissive behaviour.
|
||||||
|
try:
|
||||||
|
tf.extractall(dest, filter="data")
|
||||||
|
except TypeError:
|
||||||
tf.extractall(dest)
|
tf.extractall(dest)
|
||||||
version_file = dest / "VERSION"
|
version_file = dest / "VERSION"
|
||||||
if not version_file.is_file():
|
if not version_file.is_file():
|
||||||
|
|
@ -194,6 +203,35 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
|
||||||
return version_file.read_text().strip()
|
return version_file.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_caddyfile(source: Path) -> bool:
|
||||||
|
"""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 not source.is_file():
|
||||||
|
return False
|
||||||
|
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
|
||||||
|
return False
|
||||||
|
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy(source, _CADDYFILE_LIVE)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _link_new_units(unit_dir: Path) -> list[str]:
|
||||||
|
"""`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."""
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
return []
|
||||||
|
linked = []
|
||||||
|
for unit_file in sorted(unit_dir.iterdir()):
|
||||||
|
if unit_file.suffix not in (".service", ".timer"):
|
||||||
|
continue
|
||||||
|
target = _SYSTEMD_DIR / unit_file.name
|
||||||
|
if target.exists() or target.is_symlink():
|
||||||
|
continue
|
||||||
|
_run(["systemctl", "link", str(unit_file)])
|
||||||
|
linked.append(unit_file.name)
|
||||||
|
return linked
|
||||||
|
|
||||||
|
|
||||||
def write_state(stage: str, **extra) -> None:
|
def write_state(stage: str, **extra) -> None:
|
||||||
state_path().parent.mkdir(parents=True, exist_ok=True)
|
state_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = state_path().with_suffix(".tmp")
|
tmp = state_path().with_suffix(".tmp")
|
||||||
|
|
@ -296,7 +334,14 @@ def apply_update(tarball: Path, version: str) -> None:
|
||||||
|
|
||||||
write_state("restarting", latest=version)
|
write_state("restarting", latest=version)
|
||||||
try:
|
try:
|
||||||
|
# Copy new Caddyfile into /etc/caddy/ if the release changed routes.
|
||||||
|
# reload always runs afterwards to flush the file-handle cache so the
|
||||||
|
# symlink flip takes effect even when Caddyfile itself didn't change.
|
||||||
|
_refresh_caddyfile(target / "assets" / "Caddyfile")
|
||||||
_run(["systemctl", "reload", "caddy"])
|
_run(["systemctl", "reload", "caddy"])
|
||||||
|
# Pick up any new systemd unit files added by this release. Existing
|
||||||
|
# linked units don't need relinking — daemon-reload rereads them.
|
||||||
|
_link_new_units(target / "assets" / "systemd")
|
||||||
_run(["systemctl", "daemon-reload"])
|
_run(["systemctl", "daemon-reload"])
|
||||||
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
|
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
|
||||||
_run(["systemctl", "restart", "furtka-api.service"])
|
_run(["systemctl", "restart", "furtka-api.service"])
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ def updater(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("FURTKA_ROOT", str(tmp_path / "opt_furtka"))
|
monkeypatch.setenv("FURTKA_ROOT", str(tmp_path / "opt_furtka"))
|
||||||
monkeypatch.setenv("FURTKA_STATE_DIR", str(tmp_path / "var_lib_furtka"))
|
monkeypatch.setenv("FURTKA_STATE_DIR", str(tmp_path / "var_lib_furtka"))
|
||||||
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
|
||||||
|
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
|
||||||
|
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
|
||||||
|
(tmp_path / "etc_systemd_system").mkdir()
|
||||||
# Reload the module so the path constants pick up the env vars.
|
# Reload the module so the path constants pick up the env vars.
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
|
@ -107,6 +110,25 @@ def test_write_and_read_state_round_trip(updater):
|
||||||
assert "updated_at" in s
|
assert "updated_at" in s
|
||||||
|
|
||||||
|
|
||||||
|
def _make_release_tarball(path: Path, version: str, caddyfile_body: str = "# caddy\n"):
|
||||||
|
"""Richer tarball with assets/Caddyfile + assets/systemd/ — enough for
|
||||||
|
apply_update's post-swap integration (caddy refresh, unit linking)."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||||
|
for name, content in [
|
||||||
|
("VERSION", f"{version}\n"),
|
||||||
|
("furtka/__init__.py", ""),
|
||||||
|
("apps/fileshare/manifest.json", "{}"),
|
||||||
|
("assets/Caddyfile", caddyfile_body),
|
||||||
|
("assets/systemd/furtka-api.service", "[Service]\nExecStart=/bin/true\n"),
|
||||||
|
]:
|
||||||
|
data = content.encode()
|
||||||
|
info = tarfile.TarInfo(name=name)
|
||||||
|
info.size = len(data)
|
||||||
|
tf.addfile(info, io.BytesIO(data))
|
||||||
|
path.write_bytes(buf.getvalue())
|
||||||
|
|
||||||
|
|
||||||
def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
|
def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
|
||||||
# Set up an existing "26.0-alpha" current symlink so apply_update has
|
# Set up an existing "26.0-alpha" current symlink so apply_update has
|
||||||
# something to swap out.
|
# something to swap out.
|
||||||
|
|
@ -118,7 +140,7 @@ def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
|
||||||
current.symlink_to(versions / "26.0-alpha")
|
current.symlink_to(versions / "26.0-alpha")
|
||||||
|
|
||||||
tar = tmp_path / "t.tar.gz"
|
tar = tmp_path / "t.tar.gz"
|
||||||
_make_tarball(tar, "26.1-alpha")
|
_make_release_tarball(tar, "26.1-alpha", caddyfile_body="# new caddy config\n")
|
||||||
|
|
||||||
# Stub the shell-out + health check — both succeed.
|
# Stub the shell-out + health check — both succeed.
|
||||||
monkeypatch.setattr(updater, "_run", lambda cmd: None)
|
monkeypatch.setattr(updater, "_run", lambda cmd: None)
|
||||||
|
|
@ -128,6 +150,8 @@ def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
|
||||||
|
|
||||||
assert current.resolve() == (versions / "26.1-alpha").resolve()
|
assert current.resolve() == (versions / "26.1-alpha").resolve()
|
||||||
assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha"
|
assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha"
|
||||||
|
# P1-2: Caddyfile was copied into /etc/caddy/ from the new version.
|
||||||
|
assert updater._CADDYFILE_LIVE.read_text() == "# new caddy config\n"
|
||||||
state = updater.read_state()
|
state = updater.read_state()
|
||||||
assert state["stage"] == "done"
|
assert state["stage"] == "done"
|
||||||
assert state["version"] == "26.1-alpha"
|
assert state["version"] == "26.1-alpha"
|
||||||
|
|
@ -163,6 +187,80 @@ def test_apply_update_rolls_back_on_health_check_failure(tmp_path, updater, monk
|
||||||
assert state["failed_version"] == "26.1-alpha"
|
assert state["failed_version"] == "26.1-alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_caddyfile_copies_when_different(updater, tmp_path):
|
||||||
|
# Fresh /etc/caddy/ — source wins.
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.write_text("# new\n")
|
||||||
|
assert updater._refresh_caddyfile(src) is True
|
||||||
|
assert updater._CADDYFILE_LIVE.read_text() == "# new\n"
|
||||||
|
|
||||||
|
# Same content — no-op.
|
||||||
|
assert updater._refresh_caddyfile(src) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
|
||||||
|
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
||||||
|
unit_dir = tmp_path / "assets_systemd"
|
||||||
|
unit_dir.mkdir()
|
||||||
|
(unit_dir / "furtka-foo.service").write_text("[Service]\nExecStart=/bin/true\n")
|
||||||
|
(unit_dir / "furtka-bar.timer").write_text("[Timer]\nOnBootSec=1s\n")
|
||||||
|
(unit_dir / "ignored.txt").write_text("not a unit")
|
||||||
|
# Pretend furtka-foo is already linked — it must be skipped.
|
||||||
|
(updater._SYSTEMD_DIR / "furtka-foo.service").symlink_to("/dev/null")
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(updater, "_run", lambda cmd: seen.append(cmd))
|
||||||
|
|
||||||
|
linked = updater._link_new_units(unit_dir)
|
||||||
|
assert linked == ["furtka-bar.timer"]
|
||||||
|
# Only one systemctl link call — for the new timer, not the existing service.
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0][:2] == ["systemctl", "link"]
|
||||||
|
assert seen[0][2].endswith("furtka-bar.timer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
|
||||||
|
# Confirm we pass filter='data' to extractall on Python 3.12+; fall back
|
||||||
|
# cleanly on older runtimes. Capture the kwarg via a stub.
|
||||||
|
calls = []
|
||||||
|
real_open = updater.tarfile.open # capture before monkeypatching
|
||||||
|
|
||||||
|
class _Recorder:
|
||||||
|
def __init__(self, tarball):
|
||||||
|
self._tb = real_open(tarball, "r:gz")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
self._tb.close()
|
||||||
|
|
||||||
|
def getmembers(self):
|
||||||
|
return self._tb.getmembers()
|
||||||
|
|
||||||
|
def extractall(self, *args, **kwargs):
|
||||||
|
calls.append(("extractall", args, kwargs))
|
||||||
|
# Force the TypeError branch when filter is passed, then re-run
|
||||||
|
# without — matches the older-Python fallback.
|
||||||
|
if "filter" in kwargs:
|
||||||
|
raise TypeError("old python")
|
||||||
|
return self._tb.extractall(*args)
|
||||||
|
|
||||||
|
tar = tmp_path / "t.tar.gz"
|
||||||
|
_make_release_tarball(tar, "26.9-alpha")
|
||||||
|
monkeypatch.setattr(updater.tarfile, "open", lambda *a, **kw: _Recorder(tar))
|
||||||
|
|
||||||
|
dest = tmp_path / "dest"
|
||||||
|
updater._extract_tarball(tar, dest)
|
||||||
|
# First call had filter=, second (fallback) didn't.
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0][2] == {"filter": "data"}
|
||||||
|
assert calls[1][2] == {}
|
||||||
|
|
||||||
|
|
||||||
def test_apply_update_rejects_version_mismatch(tmp_path, updater, monkeypatch):
|
def test_apply_update_rejects_version_mismatch(tmp_path, updater, monkeypatch):
|
||||||
versions = updater.versions_dir()
|
versions = updater.versions_dir()
|
||||||
versions.mkdir(parents=True)
|
versions.mkdir(parents=True)
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ def test_resource_manager_extracts_to_versioned_slot(install_cmds):
|
||||||
assert "/opt/furtka/versions" in extract_cmd
|
assert "/opt/furtka/versions" in extract_cmd
|
||||||
assert "staging-" in extract_cmd # mktemp -d pattern
|
assert "staging-" in extract_cmd # mktemp -d pattern
|
||||||
assert 'cat "$staging/VERSION"' in extract_cmd
|
assert 'cat "$staging/VERSION"' in extract_cmd
|
||||||
|
# An empty VERSION file must abort the install instead of silently
|
||||||
|
# moving the staging dir into versions/ as a subdir.
|
||||||
|
assert '[ -n "$ver" ]' in extract_cmd
|
||||||
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd
|
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ def _resource_manager_commands():
|
||||||
"staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && "
|
"staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && "
|
||||||
f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && '
|
f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && '
|
||||||
'ver=$(cat "$staging/VERSION") && '
|
'ver=$(cat "$staging/VERSION") && '
|
||||||
|
# Guard against an empty VERSION file: without this, `mv "$staging"
|
||||||
|
# "/opt/furtka/versions/"` would move the staging dir into versions/
|
||||||
|
# as a subdir and the symlink target would be invalid.
|
||||||
|
'[ -n "$ver" ] || { echo "empty VERSION in payload" >&2; exit 1; } && '
|
||||||
'mv "$staging" "/opt/furtka/versions/$ver" && '
|
'mv "$staging" "/opt/furtka/versions/$ver" && '
|
||||||
'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
|
'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue