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>
549 lines
21 KiB
Python
549 lines
21 KiB
Python
# ruff: noqa: E501 — inline HTML/CSS/JS payloads (_INDEX_HTML, _STYLE_CSS,
|
|
# _CADDYFILE, _FURTKA_STATUS_SH, etc.) round-trip verbatim to the installed
|
|
# system; wrapping them hurts readability and the rendered output is what
|
|
# matters.
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from drives import list_scored_devices
|
|
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
|
|
|
app = Flask(__name__)
|
|
|
|
LANGUAGES = {
|
|
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
|
|
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
|
|
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski", "keyboard": "pl"},
|
|
}
|
|
|
|
STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka"))
|
|
INSTALL_LOG = STATE_DIR / "install.log"
|
|
CONFIG_PATH = STATE_DIR / "user_configuration.json"
|
|
CREDS_PATH = STATE_DIR / "user_credentials.json"
|
|
|
|
# Pre-populated with sane defaults so the form has something useful on first
|
|
# render. POSTs validate and overwrite.
|
|
settings = {
|
|
"hostname": "furtka",
|
|
"username": "",
|
|
"password": "",
|
|
"language": "en",
|
|
"boot_drive": "",
|
|
}
|
|
|
|
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
|
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
|
|
|
# Ordered phase markers for the install progress bar. Each tuple is
|
|
# (substring to search for in the archinstall log, progress percent when
|
|
# reached, user-facing label). Pick the furthest phase whose marker is
|
|
# present in the log. If archinstall changes its stdout wording the bar
|
|
# stalls on the last recognized phase — the install itself keeps going.
|
|
PROGRESS_PHASES = [
|
|
("Wiping partitions", 8, "Preparing your disk"),
|
|
("Creating partitions", 12, "Creating partitions"),
|
|
("Starting installation", 15, "Starting installation"),
|
|
("Waiting for", 18, "Syncing time and packages"),
|
|
("Installing packages: ['base'", 25, "Installing the base system (this takes a while)"),
|
|
("Adding bootloader", 65, "Setting up boot"),
|
|
("Installing packages: ['efibootmgr'", 70, "Setting up boot"),
|
|
("Installing packages: ['docker'", 80, "Installing your apps"),
|
|
("Enabling service", 90, "Turning on services"),
|
|
("Updating /mnt/etc/fstab", 95, "Almost done"),
|
|
("Installation completed without any errors", 100, "Done!"),
|
|
]
|
|
|
|
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
|
|
|
|
|
|
def parse_install_progress(log):
|
|
percent = 2
|
|
phase = "Starting up…"
|
|
for marker, pct, label in PROGRESS_PHASES:
|
|
if marker in log:
|
|
percent = pct
|
|
phase = label
|
|
|
|
if percent >= 100:
|
|
status = "done"
|
|
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
|
|
status = "error"
|
|
phase = "Installation failed — open Show details below"
|
|
else:
|
|
status = "running"
|
|
|
|
return {"percent": percent, "phase": phase, "status": status}
|
|
|
|
|
|
def validate_step1(form):
|
|
errors = []
|
|
values = {
|
|
"hostname": form.get("hostname", "").strip(),
|
|
"username": form.get("username", "").strip(),
|
|
"password": form.get("password", ""),
|
|
"language": form.get("language", ""),
|
|
}
|
|
password2 = form.get("password2", "")
|
|
|
|
if not HOSTNAME_RE.match(values["hostname"]):
|
|
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
|
|
if not USERNAME_RE.match(values["username"]):
|
|
errors.append("Username must start with a letter or underscore, lowercase only.")
|
|
if len(values["password"]) < 8:
|
|
errors.append("Password must be at least 8 characters.")
|
|
if values["password"] != password2:
|
|
errors.append("Passwords do not match.")
|
|
if values["language"] not in LANGUAGES:
|
|
errors.append("Pick a language.")
|
|
return errors, values
|
|
|
|
|
|
def build_disk_config(boot_drive):
|
|
# archinstall 4.x dropped the `use_entire_disk` shortcut — `default_layout`
|
|
# now requires fully-specified partitions. We call suggest_single_disk_layout
|
|
# with ext4 + no separate /home, which short-circuits its interactive prompts.
|
|
import asyncio
|
|
|
|
from archinstall.lib.disk.device_handler import device_handler
|
|
from archinstall.lib.disk.disk_menu import suggest_single_disk_layout
|
|
from archinstall.lib.models.device import (
|
|
DiskLayoutConfiguration,
|
|
DiskLayoutType,
|
|
FilesystemType,
|
|
)
|
|
|
|
device_handler.load_devices()
|
|
device = device_handler.get_device(Path(boot_drive))
|
|
if device is None:
|
|
raise RuntimeError(f"archinstall could not resolve device {boot_drive!r}")
|
|
|
|
device_mod = asyncio.run(
|
|
suggest_single_disk_layout(
|
|
device,
|
|
# archinstall renamed the enum members to ALL_CAPS at some point
|
|
# between when we wrote this and the pinned Arch live ISO version.
|
|
# The old name `Ext4` now raises AttributeError at install time.
|
|
filesystem_type=FilesystemType.EXT4,
|
|
separate_home=False,
|
|
)
|
|
)
|
|
layout = DiskLayoutConfiguration(
|
|
config_type=DiskLayoutType.Default,
|
|
device_modifications=[device_mod],
|
|
)
|
|
return layout.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Post-install bootstrap payload
|
|
#
|
|
# Written into the target system via archinstall's `custom_commands` so that
|
|
# after reboot the user lands in "Furtka": Caddy serves a branded landing
|
|
# page + live status tiles on :80, avahi advertises proksi.local, and the
|
|
# console shows a welcome banner pointing at the URL.
|
|
#
|
|
# Asset files (HTML, CSS, shell scripts, systemd units, Caddyfile) live in
|
|
# assets/ in the repo — at ISO build time they end up on the live ISO
|
|
# as part of the webinstaller's source tree AND inside the resource-manager
|
|
# payload tarball. The installer reads them from the live-ISO copy, base64-
|
|
# encodes them, and hands them to archinstall so the chroot recreates each
|
|
# file bit-for-bit. Updates (Phase 2) refresh the tarball, which carries the
|
|
# same assets to the target's /opt/furtka/ tree.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Tarball built by iso/build.sh containing the furtka/ Python package + the
|
|
# bundled apps/ tree (plus assets/). The webinstaller reads it from the
|
|
# live ISO at request-time and base64-encodes it into a custom_command for
|
|
# archinstall.
|
|
RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz")
|
|
|
|
|
|
# Asset root. Two layouts we have to handle:
|
|
# dev / tests — webinstaller/app.py sits at repo_root/webinstaller/ and
|
|
# assets live at repo_root/assets/.
|
|
# live ISO — iso/build.sh copies webinstaller/ to /opt/furtka/ AND
|
|
# copies assets/ to /opt/assets/ right next to
|
|
# app.py, so the same "assets next to me" lookup works.
|
|
# Probe the sibling path first (ISO case), fall back to the repo layout.
|
|
def _resolve_assets_dir() -> Path:
|
|
here = Path(__file__).resolve().parent
|
|
sibling = here / "assets"
|
|
if sibling.is_dir():
|
|
return sibling
|
|
repo_copy = here.parent / "assets"
|
|
if repo_copy.is_dir():
|
|
return repo_copy
|
|
raise FileNotFoundError(
|
|
f"furtka assets not found near {here} — looked in {sibling} and {repo_copy}"
|
|
)
|
|
|
|
|
|
_ASSETS_DIR = _resolve_assets_dir()
|
|
|
|
|
|
def _read_asset(relpath: str) -> str:
|
|
"""Return the UTF-8 contents of an on-disk asset shipped under assets/.
|
|
|
|
Raises FileNotFoundError if the asset is missing, which is loud by design:
|
|
an install that tries to write an asset that isn't there is broken before
|
|
the user ever boots the target, not after.
|
|
"""
|
|
path = _ASSETS_DIR / relpath
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
_FURTKA_WRAPPER_SH = """\
|
|
#!/bin/sh
|
|
# Tiny launcher for the furtka resource-manager CLI. The Python source lives
|
|
# under /opt/furtka/current/furtka/ — /current is a symlink that gets
|
|
# flipped by self-updates (Phase 2), so this shim stays stable across
|
|
# upgrades while the underlying code tree is swapped atomically.
|
|
PYTHONPATH=/opt/furtka/current exec python3 -m furtka.cli "$@"
|
|
"""
|
|
|
|
|
|
def _write_file_cmd(path, content, mode=None):
|
|
"""Shell command that recreates `path` with `content` inside the chroot.
|
|
|
|
Uses base64 so we don't have to worry about bash / JSON / archinstall
|
|
quoting the payload through three layers of shell. `base64` is part of
|
|
coreutils and always available in the target system.
|
|
"""
|
|
b64 = base64.b64encode(content.encode()).decode()
|
|
parent = path.rsplit("/", 1)[0]
|
|
cmd = f"mkdir -p {parent} && printf %s {b64} | base64 -d > {path}"
|
|
if mode is not None:
|
|
cmd += f" && chmod {mode} {path}"
|
|
return cmd
|
|
|
|
|
|
_FURTKA_UNITS = (
|
|
"furtka-api.service",
|
|
"furtka-reconcile.service",
|
|
"furtka-status.service",
|
|
"furtka-status.timer",
|
|
"furtka-welcome.service",
|
|
)
|
|
|
|
|
|
def _resource_manager_commands():
|
|
"""Commands to land /opt/furtka/versions/<ver>/ + symlink /opt/furtka/current
|
|
+ the `furtka` CLI shim + systemctl-link the unit files.
|
|
|
|
Reads the payload tarball staged into the live ISO at build time. If the
|
|
file isn't present (dev box without an ISO build), returns [] so the rest
|
|
of the install still works — the resource manager just won't be installed,
|
|
and nothing else on the system references furtka-* units.
|
|
"""
|
|
if not RESOURCE_MANAGER_PAYLOAD.exists():
|
|
print(
|
|
f"warning: {RESOURCE_MANAGER_PAYLOAD} missing, "
|
|
"resource manager will NOT be installed on target",
|
|
file=sys.stderr,
|
|
)
|
|
return []
|
|
payload_b64 = base64.b64encode(RESOURCE_MANAGER_PAYLOAD.read_bytes()).decode()
|
|
# Extract to a staging directory first, then rename to versions/<ver>/.
|
|
# That way the version-ID lookup is data-driven (reads VERSION from the
|
|
# tarball) instead of hardcoded at install-time — keeps the installer
|
|
# version-agnostic so a newer ISO doesn't need a webinstaller change to
|
|
# ship a new Furtka version.
|
|
extract_and_link = (
|
|
"mkdir -p /opt/furtka/versions && "
|
|
"staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && "
|
|
f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && '
|
|
'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" && '
|
|
# 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(
|
|
f"/opt/furtka/current/assets/systemd/{u}" for u in _FURTKA_UNITS
|
|
)
|
|
systemctl_enable = "systemctl enable " + " ".join(_FURTKA_UNITS)
|
|
return [
|
|
extract_and_link,
|
|
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
|
|
systemctl_link,
|
|
systemctl_enable,
|
|
]
|
|
|
|
|
|
def _furtka_json_cmd(hostname):
|
|
"""Write /var/lib/furtka/furtka.json with install-time facts.
|
|
|
|
Replaces the __HOSTNAME__ sed pass — the landing page reads this file
|
|
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.
|
|
"""
|
|
return (
|
|
"mkdir -p /var/lib/furtka && "
|
|
"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"
|
|
)
|
|
|
|
|
|
def _post_install_commands(hostname):
|
|
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
|
# the hosts line so `*.local` works from the installed system too. Guarded
|
|
# so a re-run (or a future Arch default that already ships mdns) is a
|
|
# no-op instead of double-injecting.
|
|
nss_sed = (
|
|
"grep -q 'mdns_minimal' /etc/nsswitch.conf || "
|
|
"sed -i '/^hosts:/ s/resolve/mdns_minimal [NOTFOUND=return] resolve/' "
|
|
"/etc/nsswitch.conf"
|
|
)
|
|
return [
|
|
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
|
# (systemd unit points there). Content comes from the shipped asset,
|
|
# which we copy in at install time so updates that change routing
|
|
# need a new release to refresh it.
|
|
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
|
|
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
|
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
|
nss_sed,
|
|
# Resource manager bootstrap: extract tarball → versions/<ver>/,
|
|
# symlink current, install wrapper, systemctl-link unit files.
|
|
*_resource_manager_commands(),
|
|
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
|
|
# run after the resource-manager commands.
|
|
_furtka_json_cmd(hostname),
|
|
]
|
|
|
|
|
|
def _detect_bootloader():
|
|
# systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError
|
|
# inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI.
|
|
return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub"
|
|
|
|
|
|
def build_archinstall_config(s):
|
|
return {
|
|
"archinstall-language": "English",
|
|
"timezone": "Europe/Berlin",
|
|
"ntp": True,
|
|
"bootloader": _detect_bootloader(),
|
|
"disk_config": build_disk_config(s["boot_drive"]),
|
|
"hostname": s["hostname"],
|
|
"kernels": ["linux"],
|
|
"packages": [
|
|
"docker",
|
|
"docker-compose",
|
|
# Editors for console/SSH recovery — `nano` is the beginner-friendly
|
|
# one, `vim` stays because it's muscle-memory for the dev team.
|
|
"nano",
|
|
"vim",
|
|
"git",
|
|
"htop",
|
|
"curl",
|
|
# Remote access — archinstall 4.x's `ssh: True` flag is flaky about
|
|
# actually pulling in openssh, so list it explicitly and enable sshd
|
|
# via `services` below. Without this, the documented recovery path
|
|
# (SSH in → edit .env) doesn't work.
|
|
"openssh",
|
|
# Base OS post-install (landing page + mDNS on installed system).
|
|
"caddy",
|
|
"avahi",
|
|
"nss-mdns",
|
|
# Resource manager runtime — pure-stdlib Python, no pip needed
|
|
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
|
|
"python",
|
|
],
|
|
"profile": {"type": "server"},
|
|
"services": [
|
|
"docker",
|
|
# Base OS post-install services. Only packaged units go here —
|
|
# archinstall runs `systemctl enable` on this list *before*
|
|
# custom_commands, so our own furtka-welcome + furtka-status.timer
|
|
# units (written in custom_commands) are enabled there instead.
|
|
"caddy",
|
|
"avahi-daemon",
|
|
"sshd",
|
|
],
|
|
# `gpasswd -a <user> docker` has to stay first — adds the user to
|
|
# the docker group once the group exists (archinstall creates users
|
|
# before pacstrapping extras). After that we drop the Furtka landing
|
|
# page, status timer, and welcome banner into place.
|
|
"custom_commands": [
|
|
f"gpasswd -a {s['username']} docker",
|
|
*_post_install_commands(s["hostname"]),
|
|
],
|
|
"network_config": {"type": "iso"},
|
|
"ssh": True,
|
|
"audio_config": None,
|
|
"locale_config": {
|
|
"locale": LANGUAGES[s["language"]]["locale"],
|
|
# Keyboard layout follows the chosen language so a German user
|
|
# doesn't get a US layout at the TTY console (where things like
|
|
# `/`, `-`, `=` land on surprising keys and make even `sudo vim`
|
|
# painful). `en` falls through to "us" which is what we want.
|
|
"keyboard_layout": LANGUAGES[s["language"]]["keyboard"],
|
|
},
|
|
}
|
|
|
|
|
|
def build_archinstall_creds(s):
|
|
# archinstall 4.x expects `!root-password` and `!password` (plaintext
|
|
# sentinels). Users with neither `!password` nor `enc_password` are
|
|
# silently dropped by User.parse_arguments — hence login failures.
|
|
return {
|
|
"!root-password": s["password"],
|
|
"users": [
|
|
{
|
|
"username": s["username"],
|
|
"!password": s["password"],
|
|
"sudo": True,
|
|
"groups": [],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def write_install_files(s, state_dir):
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
config_path = state_dir / "user_configuration.json"
|
|
creds_path = state_dir / "user_credentials.json"
|
|
config_path.write_text(json.dumps(build_archinstall_config(s), indent=2))
|
|
creds_path.write_text(json.dumps(build_archinstall_creds(s), indent=2))
|
|
creds_path.chmod(0o600)
|
|
return config_path, creds_path
|
|
|
|
|
|
def spawn_archinstall(config_path, creds_path, log_path):
|
|
log_fh = open(log_path, "wb")
|
|
return subprocess.Popen(
|
|
[
|
|
"archinstall",
|
|
"--config",
|
|
str(config_path),
|
|
"--creds",
|
|
str(creds_path),
|
|
"--silent",
|
|
],
|
|
stdout=log_fh,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
|
|
|
|
@app.route("/")
|
|
def home():
|
|
return redirect(url_for("install_step_1"))
|
|
|
|
|
|
@app.route("/install/step1", methods=["GET", "POST"])
|
|
def install_step_1():
|
|
errors = []
|
|
if request.method == "POST":
|
|
errors, values = validate_step1(request.form)
|
|
if not errors:
|
|
settings.update(values)
|
|
return redirect(url_for("install_step_2"))
|
|
form_values = values
|
|
else:
|
|
form_values = {k: settings[k] for k in ("hostname", "username", "language")}
|
|
return render_template(
|
|
"install/step1.html",
|
|
values=form_values,
|
|
languages=LANGUAGES,
|
|
errors=errors,
|
|
)
|
|
|
|
|
|
@app.route("/install/step2", methods=["GET", "POST"])
|
|
def install_step_2():
|
|
if request.method == "POST":
|
|
boot_drive = request.form.get("boot_drive", "").strip()
|
|
if boot_drive:
|
|
settings["boot_drive"] = boot_drive
|
|
return redirect(url_for("install_overview"))
|
|
return render_template(
|
|
"install/step2.html",
|
|
drives=list_scored_devices(),
|
|
selected=settings.get("boot_drive", ""),
|
|
)
|
|
|
|
|
|
@app.route("/install/overview")
|
|
def install_overview():
|
|
masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
|
|
return render_template("install/overview.html", settings=masked)
|
|
|
|
|
|
@app.route("/install/run", methods=["POST"])
|
|
def install_run():
|
|
if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
|
|
return redirect(url_for("install_step_1"))
|
|
config_path, creds_path = write_install_files(settings, STATE_DIR)
|
|
INSTALL_LOG.write_bytes(b"")
|
|
if os.environ.get("FURTKA_DRY_RUN") == "1":
|
|
INSTALL_LOG.write_text(
|
|
f"DRY RUN: would exec archinstall --config {config_path} "
|
|
f"--creds {creds_path} --silent\n"
|
|
)
|
|
else:
|
|
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
|
|
return redirect(url_for("install_log_view"))
|
|
|
|
|
|
@app.route("/install/log")
|
|
def install_log_view():
|
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
|
return render_template(
|
|
"install/log.html",
|
|
log=log,
|
|
progress=parse_install_progress(log),
|
|
)
|
|
|
|
|
|
@app.route("/install/log.json")
|
|
def install_log_json():
|
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
|
return jsonify(log=log, progress=parse_install_progress(log))
|
|
|
|
|
|
@app.route("/install/reboot", methods=["POST"])
|
|
def install_reboot():
|
|
# Only allow rebooting once the install has actually finished — we don't
|
|
# want a panicked click during install to reboot mid-pacstrap.
|
|
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
|
if parse_install_progress(log)["status"] != "done":
|
|
return redirect(url_for("install_log_view"))
|
|
# Delay reboot a few seconds so the browser can finish fetching CSS / assets
|
|
# for the rebooting page before the Flask server (and network) go away.
|
|
# Without this, the reboot page renders unstyled (giant inline SVG icon).
|
|
subprocess.Popen(
|
|
["/bin/sh", "-c", "sleep 3 && /usr/bin/systemctl reboot"],
|
|
start_new_session=True,
|
|
)
|
|
return render_template("install/rebooting.html", hostname=settings["hostname"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(debug=True, port=5000)
|