Walking-skeleton install on a real VM surfaced two archinstall 4.x schema breakages that the wizard hit only at runtime: - `use_entire_disk` was removed as a `config_type`. Now builds a full `default_layout` disk_config by calling `suggest_single_disk_layout` (forced ext4 + no separate /home, which bypasses its interactive prompts) and serializing the returned DeviceModification. - Credentials keys renamed to plaintext sentinels: `!root-password` and `!password`. Users with neither `!password` nor `enc_password` are silently dropped by `User.parse_arguments` — which is why the first real install booted but wouldn't log in. Also rolls in Robert's UX feedback quick-wins: `(Recommended)` prefix on the default boot entry across GRUB/syslinux/systemd-boot, and less-jargon hints on the step-1 hostname/username fields. iso/README loses three stale bullets that described pre-15b876c behaviour. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
7 KiB
Python
224 lines
7 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from drives import list_scored_devices
|
|
from flask import Flask, redirect, render_template, request, url_for
|
|
|
|
app = Flask(__name__)
|
|
|
|
LANGUAGES = {
|
|
"en": {"locale": "en_US.UTF-8", "label": "English"},
|
|
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch"},
|
|
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski"},
|
|
}
|
|
|
|
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}$")
|
|
|
|
|
|
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,
|
|
filesystem_type=FilesystemType.Ext4,
|
|
separate_home=False,
|
|
)
|
|
)
|
|
layout = DiskLayoutConfiguration(
|
|
config_type=DiskLayoutType.Default,
|
|
device_modifications=[device_mod],
|
|
)
|
|
return layout.json()
|
|
|
|
|
|
def build_archinstall_config(s):
|
|
return {
|
|
"archinstall-language": "English",
|
|
"timezone": "Europe/Berlin",
|
|
"ntp": True,
|
|
"bootloader": "Systemd-boot",
|
|
"disk_config": build_disk_config(s["boot_drive"]),
|
|
"hostname": s["hostname"],
|
|
"kernels": ["linux"],
|
|
"packages": ["docker", "docker-compose", "vim", "git", "htop", "curl"],
|
|
"profile": {"type": "server"},
|
|
"services": ["docker"],
|
|
"network_config": {"type": "iso"},
|
|
"ssh": True,
|
|
"audio_config": None,
|
|
"locale_config": {
|
|
"locale": LANGUAGES[s["language"]]["locale"],
|
|
"keyboard_layout": "us",
|
|
},
|
|
}
|
|
|
|
|
|
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": ["docker"],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
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 "(waiting for install to start)\n"
|
|
return render_template("install/log.html", log=log)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(debug=True, port=5000)
|