fix(furtka): chmod 755 on version dir + heredoc furtka.json
Some checks failed
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Has been cancelled

Two install-path bugs surfaced by SSHing into the hot-fixed test VM:

1. mktemp creates the staging dir with mode 700 by default; the rename
   to /opt/furtka/versions/<ver>/ preserved it, and Caddy (running as
   the unprivileged `caddy` user) got 403 Forbidden because it couldn't
   traverse the version dir. Now the install + self-update both
   `chmod 755` after the rename.

2. _furtka_json_cmd was a silent no-op on the 43a39a4 VM — the
   base64-encoded body + sed substitution approach layered two sets of
   quotes through archinstall's custom_commands eval, and the sed
   step either never ran or didn't match. Replaced with a plain
   heredoc that interpolates $(date -Iseconds) and $(cat VERSION) at
   chroot runtime. Result lands /var/lib/furtka/furtka.json reliably,
   which is what the landing page's hostname chip and the settings
   page's install-date field depend on.

Both issues confirmed fixed by applying them manually on the VM
(chmod 755 /opt/furtka/versions/26.0-alpha + writing furtka.json by
hand): landing page, /apps, /settings, /furtka.json all now 200 with
correct content.

Tests updated (the chmod 755 gets asserted; the old base64+sed test
gets replaced with a heredoc-shape check; the updater test asserts
0o755 mode on the finished version dir).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 15:46:56 +02:00
parent c080764c7e
commit 19e72cf5c3
4 changed files with 40 additions and 32 deletions

View file

@ -319,6 +319,9 @@ def apply_update(tarball: Path, version: str) -> None:
if target.exists():
shutil.rmtree(target)
staging.rename(target)
# mktemp-style 700 default on the staging dir carries through the
# rename; Caddy (non-root) needs 755 to traverse /opt/furtka/current/.
target.chmod(0o755)
write_state("swapping", latest=version)
previous = None

View file

@ -150,6 +150,10 @@ def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
assert current.resolve() == (versions / "26.1-alpha").resolve()
assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha"
# Version dir is 755 so Caddy can traverse it — the staging dir came
# from mktemp-ish extractall which defaults to 700, and carries through
# the rename unless we explicitly chmod.
assert (versions / "26.1-alpha").stat().st_mode & 0o777 == 0o755
# 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()

View file

@ -78,9 +78,23 @@ def test_resource_manager_extracts_to_versioned_slot(install_cmds):
# 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
# Version dir must be 755 so Caddy (non-root) can traverse it.
assert 'chmod 755 "/opt/furtka/versions/$ver"' in extract_cmd
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd
def test_furtka_json_cmd_uses_heredoc_and_interpolates_hostname(install_cmds):
# Regression: the previous base64+sed version of this command was a
# silent no-op on some installs due to archinstall-side quoting; the
# heredoc version is the one that reliably writes furtka.json.
cmd = next((c for c in install_cmds if "/var/lib/furtka/furtka.json" in c), None)
assert cmd is not None
assert "cat > /var/lib/furtka/furtka.json <<EOF" in cmd
assert '"hostname": "testhost"' in cmd
assert "date -Iseconds" in cmd
assert "/opt/furtka/current/VERSION" in cmd
def test_resource_manager_systemctl_links_every_unit(install_cmds):
link_cmd = next((c for c in install_cmds if c.startswith("systemctl link ")), None)
assert link_cmd is not None, "no systemctl link command"
@ -95,23 +109,6 @@ def test_resource_manager_enables_all_units(install_cmds):
assert unit in enable_cmd
def test_furtka_json_written_with_hostname(install_cmds):
furtka_json = next(
(c for c in install_cmds if "/var/lib/furtka/furtka.json" in c and "mkdir" in c),
None,
)
assert furtka_json is not None
# The base64-encoded JSON body should contain the hostname we passed in.
content = _extract_written_content(furtka_json, "/var/lib/furtka/furtka.json")
assert '"hostname": "testhost"' in content
# install_date + version placeholders — the sed below substitutes them at
# install time against /opt/furtka/current/VERSION + `date -Iseconds`.
assert "__INSTALL_DATE__" in content
assert "__VERSION__" in content
assert "date -Iseconds" in furtka_json
assert "/opt/furtka/current/VERSION" in furtka_json
def test_wrapper_script_points_at_current_symlink():
assert "PYTHONPATH=/opt/furtka/current" in app._FURTKA_WRAPPER_SH

View file

@ -263,6 +263,11 @@ def _resource_manager_commands():
# 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" && '
# mktemp -d creates the staging dir with mode 700; that survives the
# mv and leaves Caddy (which runs as the `caddy` user, not root)
# unable to traverse /opt/furtka/current/ when it tries to serve
# the landing page. Open up to 755 so file_server can read.
'chmod 755 "/opt/furtka/versions/$ver" && '
'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
)
systemctl_link = "systemctl link " + " ".join(
@ -284,24 +289,23 @@ def _furtka_json_cmd(hostname):
at runtime and renders the hostname chip from it. install_date + version
ride along so the settings page can display them without hitting the
status timer's refresh cycle.
Heredoc rather than base64 + sed the previous version had two layers
of quoting that archinstall's custom_commands shell-eval path parsed
inconsistently, leaving this command as a silent no-op on some installs.
The heredoc evaluates `$(date ...)` and `$(cat VERSION)` at chroot
runtime and sidesteps the quoting hazard entirely. Hostname has already
been validated by validate_step1.
"""
# Python-side JSON assembly keeps the shell command free of quote-escape
# hazards. Hostname is validated up-front in validate_step1 so it's safe
# to interpolate.
body = json.dumps(
{
"hostname": hostname,
"install_date": "__INSTALL_DATE__",
"version": "__VERSION__",
},
indent=2,
)
return (
"mkdir -p /var/lib/furtka && "
+ _write_file_cmd("/var/lib/furtka/furtka.json", body)
+ ' && sed -i "s/__INSTALL_DATE__/$(date -Iseconds)/;'
's|__VERSION__|$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)|"'
" /var/lib/furtka/furtka.json"
"cat > /var/lib/furtka/furtka.json <<EOF\n"
"{\n"
f' "hostname": "{hostname}",\n'
' "install_date": "$(date -Iseconds)",\n'
' "version": "$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)"\n'
"}\n"
"EOF"
)