feat: install progress bar + fix docker group creation order

Two tangled changes to the install flow, batched because they're both
small and hit app.py:

1. Phase-based progress bar on /install/log. parse_install_progress()
   scans the archinstall log for ordered phase markers ("Wiping
   partitions", "Installing packages: ['base'", "Adding bootloader",
   "Installation completed without any errors", …) and exposes
   percent + user-facing phase label + status (running/done/error).
   Template wraps the raw log in a collapsed <details> so the default
   view stays calm; the meta-refresh stops once status is terminal.
   If archinstall changes its stdout wording the bar stalls on the
   last recognized phase — the install itself is unaffected.

2. Drop "docker" from the user's groups in creds and do the
   `gpasswd -a <user> docker` via custom_commands instead.
   archinstall creates users before pacstrapping the extras list, so
   the docker group doesn't exist at user-create time —
   caused the second real install to crash with
   `gpasswd: group 'docker' does not exist`. custom_commands runs
   at the very end, after docker is installed. Username is validated
   by USERNAME_RE so no shell injection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-14 17:07:57 +02:00
parent 51cdf460d9
commit 3a259beb98
3 changed files with 112 additions and 6 deletions

View file

@ -33,6 +33,46 @@ settings = {
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 = []
@ -102,6 +142,11 @@ def build_archinstall_config(s):
"packages": ["docker", "docker-compose", "vim", "git", "htop", "curl"],
"profile": {"type": "server"},
"services": ["docker"],
# Add user to the docker group post-install. We can't put "docker" in
# the user's `groups` at create-time because archinstall creates users
# before pacstrapping the extras, so the docker group doesn't exist
# yet. custom_commands runs at the very end.
"custom_commands": [f"gpasswd -a {s['username']} docker"],
"network_config": {"type": "iso"},
"ssh": True,
"audio_config": None,
@ -123,7 +168,7 @@ def build_archinstall_creds(s):
"username": s["username"],
"!password": s["password"],
"sudo": True,
"groups": ["docker"],
"groups": [],
}
],
}
@ -216,8 +261,12 @@ def install_run():
@app.route("/install/log")
def install_log_view():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "(waiting for install to start)\n"
return render_template("install/log.html", log=log)
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return render_template(
"install/log.html",
log=log,
progress=parse_install_progress(log),
)
if __name__ == "__main__":

View file

@ -337,6 +337,45 @@ select:focus {
margin: 0;
}
.progress {
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 999px;
height: 12px;
overflow: hidden;
margin: 1.5rem 0 0.5rem;
}
.progress-bar {
height: 100%;
background: linear-gradient(
90deg,
var(--accent),
color-mix(in srgb, var(--accent) 55%, #fff)
);
transition: width 0.6s ease;
border-radius: 999px;
}
.progress-bar-error { background: var(--danger); }
.progress-bar-done { background: var(--success); }
.progress-phase {
margin: 0.3rem 0 1.5rem;
color: var(--fg-muted);
font-size: 0.95rem;
}
.log-details {
margin-top: 1.5rem;
}
.log-details summary {
cursor: pointer;
font-size: 0.9rem;
color: var(--fg-muted);
user-select: none;
padding: 0.25rem 0;
}
.log-details summary:hover { color: var(--fg); }
.log-details[open] summary { margin-bottom: 0.6rem; }
/* ── Footer ─────────────────────────────────────────────────── */
.site-footer {

View file

@ -1,12 +1,30 @@
{% extends "base.html" %}
{% block title %}Installing… · Furtka Installer{% endblock %}
{% block head_extra %}<meta http-equiv="refresh" content="3">{% endblock %}
{% block head_extra %}
{% if progress.status == "running" %}<meta http-equiv="refresh" content="3">{% endif %}
{% endblock %}
{% block step_indicator %}<span class="step-indicator">Installing</span>{% endblock %}
{% block content %}
{% if progress.status == "done" %}
<h1>Furtka is ready</h1>
<p class="lede">Installation finished. Remove the USB / eject the installer image, then reboot.</p>
{% elif progress.status == "error" %}
<h1>Installation hit a snag</h1>
<p class="lede">Something went wrong. Open the details below and share them so we can help.</p>
{% else %}
<h1>Installing Furtka</h1>
<p class="lede">This page reloads every 3 seconds. Don't close it. Don't power off.</p>
<p class="lede">This takes a few minutes. Don't close this page or power off the machine.</p>
{% endif %}
<pre class="log">{{ log }}</pre>
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
<div class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
</div>
<p class="progress-phase">{{ progress.phase }} · {{ progress.percent }}%</p>
<details class="log-details">
<summary>Show details</summary>
<pre class="log">{{ log or "(waiting for install to start)" }}</pre>
</details>
{% endblock %}