feat: Auto-Modus + prozentuale Partitionierung für Mint (Phase 2)

Auto-Modus (TUXFLOTTE_AUTO_MODE=true, über config/installer.conf als
exportierte Env-Var vor 05_network.sh gesetzt) überspringt alle drei
interaktiven Gates, die bei genauerem Hinsehen existierten (nicht nur
das eine ursprünglich im Plan genannte):
- 05_network.sh: nicht-interaktiver WLAN-Pfad über TUXFLOTTE_WIFI_SSID/
  TUXFLOTTE_WIFI_PSK, analog zum bestehenden TUXFLOTTE_ACTIVATION_CODE-
  Muster in 12_enrollment_auth.sh (unverändert, unterstützte das schon).
- 17_device_status.sh: "Provisionierung fortsetzen?"-Prompt übersprungen.
- 20_profile_selection.sh: "Vorlage auswählen?"-Prompt übersprungen,
  wählt automatisch die als is_default markierte Vorlage (echter
  Server-Hinweis via Enrollment Session erst mit Phase 3 möglich).
- 25_installation_confirm.sh: eigentliches Commit-Gate übersprungen.

Alle drei Auto-Modus-Zweige lokal verifiziert (Skripte direkt mit
TUXFLOTTE_AUTO_MODE=true und präparierten Eingabedateien ausgeführt,
kein Hängenbleiben an read -p, korrekte state.env/template.json-Ausgabe).

Partitionierung: installation_directives.partitioning von einfachem
String auf strukturiertes Objekt umgestellt ({"scheme": "single"|
"custom", "root_filesystem", "extra_partitions": [{"mountpoint",
"filesystem", "percent"}]}) - Vertrag, an den sich provisioning-server
in Phase 3 halten muss. backend_generate_config() baut daraus ein
partman-auto/expert_recipe (ersetzt die bisherige choose_recipe-
Fallunterscheidung mit nur "default"/"atomic"), Prozentangaben werden
anhand der realen Zieldatenträgergröße (lsblk/blockdev, erst live auf
dem Zielgerät bekannt) in feste MB-Größen umgerechnet.

Auf UEFI-Systemen wird zusätzlich eine EFI-System-Partition ins Recipe
aufgenommen (sonst verweigert/warnt der Installer, "No EFI System
Partition was found") - exakte Stanza-Syntax nicht aus der Erinnerung
geraten, sondern aus /usr/lib/partman/recipes-amd64-efi/30atomic auf
dem echten Live-Medium ausgelesen ($reusemethod{ } war der fehlende
Teil in einem ersten, geparsten aber nicht erkannten Versuch).

backend_init() installiert jetzt auch jq/envsubst(gettext-base)/cpio
bei Bedarf nach (vorbestehende Lücke neben dem schon in Phase 1
behobenen kexec-tools).

Real per QEMU verifiziert: eigene Ein-Datenträger-Erkennung musste
gehärtet werden (nbd/zram-Geräte mit Größe 0 wurden fälschlich vor dem
echten Datenträger gewählt - auf einer sauberen VM allein wäre das nicht
aufgefallen). Custom-Recipe mit ext4-Root + ext4-/home (20%) +
btrfs-/var (10%) auf 40GB-Testplatte: vollständige unbeaufsichtigte
Installation inkl. Paketinstallation durchlaufen lassen, danach von der
Festplatte (nicht der Live-CD) gebootet - Login-Bildschirm mit korrektem
Hostname erscheint, System bootet einwandfrei per UEFI/ESP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-06 16:12:56 +02:00
parent d6a01462ef
commit 108444ecf7
7 changed files with 264 additions and 29 deletions

View File

@ -26,27 +26,35 @@ backend_fatal() {
} }
backend_init() { backend_init() {
for cmd in jq envsubst base64; do # jq/envsubst(gettext-base)/cpio/kexec-tools sind auf dem Live-Medium
command -v "${cmd}" >/dev/null 2>&1 || # selbst (anders als im Zielsystem, siehe pkgsel/include in preseed.tpl)
{ backend_fatal "Benötigtes Werkzeug fehlt: ${cmd}"; return 1; } # nicht vorinstalliert - real gegen das Live-Abbild verifiziert
done # (Phase-1-Spike, zunächst nur für kexec-tools behoben, hier auf alle
# vier Live-only-Werkzeuge ausgeweitet). base64 kommt aus coreutils und
# ist auf jedem Debian-Derivat immer vorhanden, deshalb ohne Nachinstal-
# lationspfad.
local live_packages_needed=()
# kexec-tools ist auf dem Live-Medium selbst (anders als im Zielsystem, command -v jq >/dev/null 2>&1 || live_packages_needed+=(jq)
# siehe pkgsel/include in preseed.tpl) nicht vorinstalliert - real gegen command -v envsubst >/dev/null 2>&1 || live_packages_needed+=(gettext-base)
# das Live-Abbild verifiziert (Phase-1-Spike). Bis das Paket fest in die command -v cpio >/dev/null 2>&1 || live_packages_needed+=(cpio)
# ISO gebacken wird (Folgeschritt), hier als Live-Nachinstallation. command -v kexec >/dev/null 2>&1 || live_packages_needed+=(kexec-tools)
if ! command -v kexec >/dev/null 2>&1; then
backend_log "kexec-tools fehlt auf dem Live-Medium, installiere nach." if [[ "${#live_packages_needed[@]}" -gt 0 ]]; then
backend_log "Werkzeuge fehlen auf dem Live-Medium, installiere nach: ${live_packages_needed[*]}"
sed -i '/^deb cdrom/d' /etc/apt/sources.list 2>/dev/null || true sed -i '/^deb cdrom/d' /etc/apt/sources.list 2>/dev/null || true
rm -f /etc/apt/sources.list.d/*cdrom* 2>/dev/null || true rm -f /etc/apt/sources.list.d/*cdrom* 2>/dev/null || true
apt-get update -qq || apt-get update -qq ||
{ backend_fatal "apt-get update fehlgeschlagen (kexec-tools)."; return 1; } { backend_fatal "apt-get update fehlgeschlagen."; return 1; }
DEBIAN_FRONTEND=noninteractive apt-get install -y kexec-tools || DEBIAN_FRONTEND=noninteractive apt-get install -y "${live_packages_needed[@]}" ||
{ backend_fatal "Installation von kexec-tools fehlgeschlagen."; return 1; } { backend_fatal "Installation fehlender Werkzeuge fehlgeschlagen."; return 1; }
fi fi
command -v base64 >/dev/null 2>&1 ||
{ backend_fatal "Benötigtes Werkzeug fehlt: base64"; return 1; }
[[ -r "${PRESEED_TEMPLATE}" ]] || [[ -r "${PRESEED_TEMPLATE}" ]] ||
{ backend_fatal "Preseed-Template nicht gefunden: ${PRESEED_TEMPLATE}"; return 1; } { backend_fatal "Preseed-Template nicht gefunden: ${PRESEED_TEMPLATE}"; return 1; }
@ -77,7 +85,7 @@ backend_validate() {
jq --exit-status ' jq --exit-status '
.runtime_blueprint.installation_directives .runtime_blueprint.installation_directives
| (.disk_encryption | type == "boolean") | (.disk_encryption | type == "boolean")
and (.partitioning | type == "string") and (.partitioning | type == "object")
and (.secure_boot_required | type == "boolean") and (.secure_boot_required | type == "boolean")
' "${RUNTIME_BLUEPRINT_FILE}" >/dev/null || ' "${RUNTIME_BLUEPRINT_FILE}" >/dev/null ||
{ backend_fatal "Installationszeitliche Vorgaben fehlen oder sind ungültig."; return 1; } { backend_fatal "Installationszeitliche Vorgaben fehlen oder sind ungültig."; return 1; }
@ -92,10 +100,144 @@ backend_validate() {
backend_log "Runtime Blueprint ist gültig für Backend '${BACKEND_KEY}'." backend_log "Runtime Blueprint ist gültig für Backend '${BACKEND_KEY}'."
} }
# Baut einen einzelnen partman-auto/expert_recipe-Partitionsblock als
# EINZEILIGEN String (Felder durch Leerzeichen statt Zeilenumbrueche
# getrennt) - Debconf-Preseed-Werte mit eingebetteten Zeilenumbruechen
# brechen leicht lautlos (siehe base64-Kommentar bei success_command weiter
# unten fuer denselben Fallstrick an anderer Stelle), partmans Parser selbst
# ist bei Leerzeichen als Trenner tolerant.
_tuxflotte_partman_stanza() {
local size_mb="$1"
local filesystem="$2"
local mountpoint="$3"
local extra_flags="${4:-}"
printf '%s %s %s %s %s method{ format } format{ } use_filesystem{ } filesystem{ %s } mountpoint{ %s } . ' \
"${size_mb}" "${size_mb}" "${size_mb}" "${filesystem}" "${extra_flags}" "${filesystem}" "${mountpoint}"
}
# Ermittelt den ersten echten Datentraeger des Zielgeraets fuer die
# Groessenberechnung bei prozentualer Partitionierung (Fedoras autopart macht
# implizit dieselbe Ein-Datentraeger-Annahme, siehe fedora/backend.sh).
# Groessenfilter (>0) und Namensausschluss noetig - reale Systeme koennen
# nbd-/zram-/loop-Geraete mit type=="disk" aber ohne echte Speicherkapazitaet
# auflisten, die sonst faelschlich vor dem echten Zieldatentraeger gewaehlt
# wuerden (real beim Testen entdeckt).
_tuxflotte_detect_target_disk() {
lsblk --nodeps --noheadings --bytes --output NAME,TYPE,SIZE --paths |
awk '$2 == "disk" && $3 > 0 && $1 !~ /(nbd|zram|loop)[0-9]*$/ { print $1; exit }'
}
# Baut den kompletten partman-auto/expert_recipe-Rezeptkoerper aus dem
# installation_directives.partitioning-Objekt. Prozentangaben werden anhand
# der realen Zieldatentraegergroesse (erst hier, live auf dem Zielgeraet,
# bekannt - nicht beim ISO-Bau) in feste MB-Groessen umgerechnet. Bewusst
# feste Groessen (min=priority=max) statt partmans eigener proportionaler
# Prioritaets-Verteilung - deterministischer und leichter zu verifizieren.
_tuxflotte_render_partman_recipe() {
local partitioning_json="$1"
local root_filesystem="$2"
local scheme
local disk_device
local disk_size_mb
local extra_count
local extra_percent_sum=0
local root_mb
local recipe_body=""
local i
local mountpoint
local filesystem
local percent
local size_mb
local esp_mb=0
scheme="$(jq --raw-output '.scheme // "single"' <<<"${partitioning_json}")"
disk_device="$(_tuxflotte_detect_target_disk)"
[[ -n "${disk_device}" ]] ||
{ backend_fatal "Zieldatenträger konnte nicht ermittelt werden."; return 1; }
disk_size_mb="$(( $(blockdev --getsize64 "${disk_device}") / 1024 / 1024 ))"
# Die eingebauten partman-Recipes ("atomic" etc.) legen auf UEFI-Systemen
# automatisch eine EFI-System-Partition an - ein eigenes expert_recipe
# muss das selbst tun, sonst warnt/verweigert der Installer (real beim
# Testen entdeckt: "No EFI System Partition was found"). 512 MB vorab
# reserviert, vor der Prozentaufteilung der restlichen Platte.
if [[ -d /sys/firmware/efi ]]; then
# Exakte Stanza-Form aus der eingebauten "atomic"-Recipe uebernommen
# (/usr/lib/partman/recipes-amd64-efi/30atomic auf dem Live-Medium
# ausgelesen) - eine erste eigene Vermutung ohne $reusemethod{ } und
# mit $bootable{ } wurde von partman zwar anstandslos geparst, aber
# nicht als gueltige EFI-System-Partition erkannt ("No EFI System
# Partition was found", real beim Testen entdeckt).
esp_mb=512
disk_size_mb="$(( disk_size_mb - esp_mb ))"
recipe_body="${esp_mb} ${esp_mb} ${esp_mb} fat32 \$reusemethod{ } \$primary{ } method{ efi } format{ } . "
fi
case "${scheme}" in
single)
root_mb="$(( disk_size_mb - 1024 ))"
[[ "${root_mb}" -ge 2048 ]] ||
{ backend_fatal "Zieldatenträger ist zu klein (${disk_size_mb} MB)."; return 1; }
recipe_body="${recipe_body}$(_tuxflotte_partman_stanza "${root_mb}" "${root_filesystem}" "/" '$primary{ } $bootable{ }')"
;;
custom)
extra_count="$(jq '.extra_partitions | length' <<<"${partitioning_json}")"
[[ "${extra_count}" -gt 0 ]] ||
{ backend_fatal "scheme=custom ohne extra_partitions angegeben."; return 1; }
for ((i = 0; i < extra_count; i++)); do
mountpoint="$(jq --raw-output ".extra_partitions[${i}].mountpoint" <<<"${partitioning_json}")"
filesystem="$(jq --raw-output ".extra_partitions[${i}].filesystem" <<<"${partitioning_json}")"
percent="$(jq --raw-output ".extra_partitions[${i}].percent" <<<"${partitioning_json}")"
case "${mountpoint}" in
/home|/var) ;;
*) backend_fatal "Nicht unterstützter Einhängepunkt: ${mountpoint}"; return 1 ;;
esac
case "${filesystem}" in
ext4|btrfs) ;;
*) backend_fatal "Nicht unterstütztes Dateisystem: ${filesystem}"; return 1 ;;
esac
extra_percent_sum="$(( extra_percent_sum + percent ))"
done
[[ "${extra_percent_sum}" -gt 0 && "${extra_percent_sum}" -lt 90 ]] ||
{ backend_fatal "Summe der Partitions-Prozentangaben ist ungültig: ${extra_percent_sum}"; return 1; }
root_mb="$(( disk_size_mb * (100 - extra_percent_sum) / 100 - 1024 ))"
[[ "${root_mb}" -ge 2048 ]] ||
{ backend_fatal "Root-Partition wäre bei dieser Aufteilung zu klein."; return 1; }
recipe_body="${recipe_body}$(_tuxflotte_partman_stanza "${root_mb}" "${root_filesystem}" "/" '$primary{ } $bootable{ }')"
for ((i = 0; i < extra_count; i++)); do
mountpoint="$(jq --raw-output ".extra_partitions[${i}].mountpoint" <<<"${partitioning_json}")"
filesystem="$(jq --raw-output ".extra_partitions[${i}].filesystem" <<<"${partitioning_json}")"
percent="$(jq --raw-output ".extra_partitions[${i}].percent" <<<"${partitioning_json}")"
size_mb="$(( disk_size_mb * percent / 100 ))"
recipe_body="${recipe_body}$(_tuxflotte_partman_stanza "${size_mb}" "${filesystem}" "${mountpoint}")"
done
;;
*)
backend_fatal "Nicht unterstütztes Partitionierungsschema: ${scheme}"
return 1
;;
esac
printf 'tuxflotte :: %s' "${recipe_body}"
}
backend_generate_config() { backend_generate_config() {
local hostname local hostname
local device_id local device_id
local partitioning local partitioning_json
local root_filesystem
local secure_boot_required local secure_boot_required
local partman_recipe local partman_recipe
local blueprints_json local blueprints_json
@ -113,19 +255,18 @@ backend_generate_config() {
[[ -n "${device_id}" ]] || [[ -n "${device_id}" ]] ||
{ backend_fatal "Keine Geräte-ID in der Serverantwort gefunden."; return 1; } { backend_fatal "Keine Geräte-ID in der Serverantwort gefunden."; return 1; }
partitioning="$(jq --raw-output '.runtime_blueprint.installation_directives.partitioning' "${RUNTIME_BLUEPRINT_FILE}")" partitioning_json="$(jq --compact-output '.runtime_blueprint.installation_directives.partitioning' "${RUNTIME_BLUEPRINT_FILE}")"
secure_boot_required="$(jq --raw-output '.runtime_blueprint.installation_directives.secure_boot_required' "${RUNTIME_BLUEPRINT_FILE}")" secure_boot_required="$(jq --raw-output '.runtime_blueprint.installation_directives.secure_boot_required' "${RUNTIME_BLUEPRINT_FILE}")"
case "${partitioning}" in root_filesystem="$(jq --raw-output '.root_filesystem // "ext4"' <<<"${partitioning_json}")"
default) case "${root_filesystem}" in
partman_recipe="atomic" ext4|btrfs) ;;
;; *) backend_fatal "Nicht unterstütztes Root-Dateisystem: ${root_filesystem}"; return 1 ;;
*)
backend_fatal "Nicht unterstützte Partitionierungsvorgabe: ${partitioning}"
return 1
;;
esac esac
partman_recipe="$(_tuxflotte_render_partman_recipe "${partitioning_json}" "${root_filesystem}")" ||
return 1
if [[ "${secure_boot_required}" == "true" ]]; then if [[ "${secure_boot_required}" == "true" ]]; then
backend_log "Hinweis: secure_boot_required=true wird derzeit nicht in der Preseed-Konfiguration durchgesetzt (Phase 1)." backend_log "Hinweis: secure_boot_required=true wird derzeit nicht in der Preseed-Konfiguration durchgesetzt (Phase 1)."
fi fi

View File

@ -26,7 +26,8 @@ d-i time/zone string Europe/Berlin
d-i clock-setup/ntp boolean true d-i clock-setup/ntp boolean true
d-i partman-auto/method string regular d-i partman-auto/method string regular
d-i partman-auto/choose_recipe select ${TUXFLOTTE_PARTMAN_RECIPE} d-i partman-auto/expert_recipe string ${TUXFLOTTE_PARTMAN_RECIPE}
d-i partman-auto/choose_recipe select tuxflotte
d-i partman-partitioning/confirm_write_new_label boolean true d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish d-i partman/choose_partition select finish
d-i partman/confirm boolean true d-i partman/confirm boolean true

View File

@ -124,7 +124,7 @@ bake_test_preseed() {
"blueprints": [], "blueprints": [],
"installation_directives": { "installation_directives": {
"disk_encryption": false, "disk_encryption": false,
"partitioning": "default", "partitioning": {"scheme": "single", "root_filesystem": "ext4"},
"secure_boot_required": false "secure_boot_required": false
} }
} }

View File

@ -377,6 +377,48 @@ connect_wifi() {
log "Tuxflotte-Server ist über WLAN erreichbar." log "Tuxflotte-Server ist über WLAN erreichbar."
} }
connect_wifi_noninteractive() {
local wifi_device="$1"
local ssid="${TUXFLOTTE_WIFI_SSID}"
log "Verbinde mit vorkonfiguriertem WLAN '${ssid}' (nicht-interaktiv)."
if [[ -n "${TUXFLOTTE_WIFI_PSK:-}" ]]; then
if ! "${NMCLI}" \
device wifi connect "${ssid}" \
password "${TUXFLOTTE_WIFI_PSK}" \
ifname "${wifi_device}" \
>/dev/null; then
warn "WLAN-Anmeldung mit vorkonfigurierten Zugangsdaten ist fehlgeschlagen."
return 1
fi
else
if ! "${NMCLI}" \
device wifi connect "${ssid}" \
ifname "${wifi_device}" \
>/dev/null; then
warn "Verbindung mit dem offenen, vorkonfigurierten WLAN konnte nicht hergestellt werden."
return 1
fi
fi
if ! device_has_ipv4 "${wifi_device}"; then
warn "WLAN-Verbindung besitzt keine IPv4-Adresse."
return 1
fi
log "WLAN-Verbindung wurde hergestellt."
if ! server_is_reachable; then
warn "WLAN ist verbunden, aber der Tuxflotte-Server ist nicht erreichbar."
return 1
fi
log "Tuxflotte-Server ist über WLAN erreichbar."
}
store_network_state() { store_network_state() {
local device local device
local connection_name local connection_name
@ -484,8 +526,13 @@ main() {
log "Ethernet ist nicht verfügbar. WLAN-Initialisierung wird gestartet." log "Ethernet ist nicht verfügbar. WLAN-Initialisierung wird gestartet."
connect_wifi "${wifi_device}" || if [[ -n "${TUXFLOTTE_WIFI_SSID:-}" ]]; then
fatal "Es konnte keine Verbindung zum Tuxflotte-Server hergestellt werden." connect_wifi_noninteractive "${wifi_device}" ||
fatal "Es konnte keine Verbindung zum Tuxflotte-Server hergestellt werden."
else
connect_wifi "${wifi_device}" ||
fatal "Es konnte keine Verbindung zum Tuxflotte-Server hergestellt werden."
fi
fi fi
store_network_state store_network_state

View File

@ -101,6 +101,12 @@ show_device_status() {
confirm_provisioning() { confirm_provisioning() {
local answer local answer
if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then
store_provisioning_state true
log "Auto-Modus: Provisionierung wird ohne Rückfrage fortgesetzt."
return 0
fi
printf '\n' printf '\n'
read -r -p "Provisionierung fortsetzen? [j/N]: " answer read -r -p "Provisionierung fortsetzen? [j/N]: " answer

View File

@ -72,7 +72,33 @@ show_templates() {
done < <(jq -c '.templates[]' "${SERVER_RESPONSE_FILE}") done < <(jq -c '.templates[]' "${SERVER_RESPONSE_FILE}")
} }
select_template() { select_template_auto() {
local default_index
# Kein spezifischer Server-Hinweis vorhanden (Enrollment Sessions legen
# serverseitig zwar schon eine Bereitstellungsvorlage fest, die
# Verbrauchslogik dafür existiert aber noch nicht - siehe Phase 3 im
# Plan) - bis dahin wird im Auto-Modus die als is_default markierte
# Vorlage gewählt.
default_index="$(
jq '[.templates[] | .is_default] | index(true)' "${SERVER_RESPONSE_FILE}"
)"
[[ "${default_index}" != "null" ]] ||
fatal "Auto-Modus: keine Standard-Bereitstellungsvorlage in der Serverantwort markiert."
jq \
--argjson index "${default_index}" \
'{
schema_version: 1,
template: .templates[$index]
}' \
"${SERVER_RESPONSE_FILE}" >"${TEMPLATE_FILE}"
log "Auto-Modus: Standard-Bereitstellungsvorlage automatisch gewählt."
}
select_template_interactive() {
local template_count local template_count
local selection local selection
@ -97,6 +123,14 @@ select_template() {
template: .templates[$index] template: .templates[$index]
}' \ }' \
"${SERVER_RESPONSE_FILE}" >"${TEMPLATE_FILE}" "${SERVER_RESPONSE_FILE}" >"${TEMPLATE_FILE}"
}
select_template() {
if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then
select_template_auto
else
select_template_interactive
fi
chmod 0600 "${TEMPLATE_FILE}" chmod 0600 "${TEMPLATE_FILE}"

View File

@ -87,6 +87,12 @@ store_confirmation_state() {
confirm_installation() { confirm_installation() {
local answer local answer
if [[ "${TUXFLOTTE_AUTO_MODE:-false}" == "true" ]]; then
store_confirmation_state true
log "Auto-Modus: Installation wird ohne Rückfrage gestartet."
return 0
fi
read -r -p "Installation jetzt starten? [j/N]: " answer read -r -p "Installation jetzt starten? [j/N]: " answer
case "${answer}" in case "${answer}" in