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(): if target.exists():
shutil.rmtree(target) shutil.rmtree(target)
staging.rename(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) write_state("swapping", latest=version)
previous = None 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 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"
# 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. # P1-2: Caddyfile was copied into /etc/caddy/ from the new version.
assert updater._CADDYFILE_LIVE.read_text() == "# new caddy config\n" assert updater._CADDYFILE_LIVE.read_text() == "# new caddy config\n"
state = updater.read_state() 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 # An empty VERSION file must abort the install instead of silently
# moving the staging dir into versions/ as a subdir. # moving the staging dir into versions/ as a subdir.
assert '[ -n "$ver" ]' in extract_cmd 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 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): 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) 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" 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 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(): def test_wrapper_script_points_at_current_symlink():
assert "PYTHONPATH=/opt/furtka/current" in app._FURTKA_WRAPPER_SH 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. # as a subdir and the symlink target would be invalid.
'[ -n "$ver" ] || { echo "empty VERSION in payload" >&2; exit 1; } && ' '[ -n "$ver" ] || { echo "empty VERSION in payload" >&2; exit 1; } && '
'mv "$staging" "/opt/furtka/versions/$ver" && ' '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' 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
) )
systemctl_link = "systemctl link " + " ".join( 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 at runtime and renders the hostname chip from it. install_date + version
ride along so the settings page can display them without hitting the ride along so the settings page can display them without hitting the
status timer's refresh cycle. 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 ( return (
"mkdir -p /var/lib/furtka && " "mkdir -p /var/lib/furtka && "
+ _write_file_cmd("/var/lib/furtka/furtka.json", body) "cat > /var/lib/furtka/furtka.json <<EOF\n"
+ ' && sed -i "s/__INSTALL_DATE__/$(date -Iseconds)/;' "{\n"
's|__VERSION__|$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)|"' f' "hostname": "{hostname}",\n'
" /var/lib/furtka/furtka.json" ' "install_date": "$(date -Iseconds)",\n'
' "version": "$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)"\n'
"}\n"
"EOF"
) )