Boot-Medium neutral benannt (TUXFLOTTE_BOOT_MEDIUM_PATH), Offline-Medium, Neuinstallation ohne Rueckfrage pro Enrollment Session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 12:17:31 +02:00
1 parent 43d0fce764
commit 5ed6d4d478
3 files changed
+93 -22

No files matched your search

+81 -21
View File
@@ -46,15 +46,22 @@ STANDARD_OE_NAME = "Neue Geräte"
# Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/
# build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode
# geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH
# das einmalig von der offiziellen Downloadseite geholte Quellabbild.
# geklontes Repo (nicht Teil von provisioning-server selbst), BOOT_MEDIUM_PATH
# das eigene, distributionsunabhaengige Boot-Medium (build_boot_medium.sh +
# publish_boot_medium.sh), aus dem JEDE Kunden-ISO gebaut wird - egal ob Mint,
# Debian oder Ubuntu. Hiess frueher MINT_ISO_PATH (aus der Zeit, als die
# Mint-Live-ISO gepatcht wurde); die alte Variable gilt als Rueckfallebene.
TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer")
# Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit
# cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt
# relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service)
# gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen.
MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve())
# Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an
BOOT_MEDIUM_PATH = str(Path(
os.environ.get("TUXFLOTTE_BOOT_MEDIUM_PATH")
or os.environ.get("TUXFLOTTE_MINT_ISO_PATH")
or "data/upstream/tuxflotte-boot-medium.iso"
).resolve())
# Aus demselben Grund wie BOOT_MEDIUM_PATH absolut aufloesen: der als $2 an
# build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ
# zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ
# zum eigenen Arbeitsverzeichnis.
@@ -275,6 +282,7 @@ class CreateEnrollmentSessionRequest(BaseModel):
bereitstellungsvorlage_id: str
max_devices: int
expires_at: str
neuinstallation_ohne_rueckfrage: bool = False
class RechargeEnrollmentSessionRequest(BaseModel):
@@ -289,6 +297,8 @@ class CreateIsoBuildRequest(BaseModel):
# Diagnosezugang (02.10.2026): nur der SHA-512-crypt-Hash, nie das
# Passwort selbst - das kennt allein die Kundenplattform (Root-Admin).
root_password_hash: str | None = None
# Offline-Medium (Roadmap V5): Golden Image auf die ISO legen.
offline_medium: bool = False
class CreateOrganisationseinheitRequest(BaseModel):
@@ -609,7 +619,8 @@ def find_enrollment_session(code: str):
cur.execute(
"""
SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id,
es.max_devices, es.devices_used, es.expires_at, es.revoked_at
es.max_devices, es.devices_used, es.expires_at, es.revoked_at,
es.neuinstallation_ohne_rueckfrage
FROM enrollment_sessions es
JOIN organizations o ON o.id = es.organization_id
WHERE es.id = %s
@@ -623,7 +634,7 @@ def find_enrollment_session(code: str):
(
session_id, organization_id, organization_name, bereitstellungsvorlage_id,
max_devices, devices_used, expires_at, revoked_at,
max_devices, devices_used, expires_at, revoked_at, neuinstallation_ohne_rueckfrage,
) = row
if revoked_at is not None:
@@ -638,6 +649,7 @@ def find_enrollment_session(code: str):
"organization_id": str(organization_id),
"organization_name": organization_name,
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id),
"neuinstallation_ohne_rueckfrage": bool(neuinstallation_ohne_rueckfrage),
}
def consume_enrollment_session(session_id: str) -> None:
@@ -3481,7 +3493,10 @@ def fetch_enrollment_sessions(organization_id: str):
) in cur.fetchall()
]
def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str):
def create_enrollment_session(
organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str,
neuinstallation_ohne_rueckfrage: bool = False,
):
session_id = uuid4()
with get_database_connection() as conn:
@@ -3489,10 +3504,12 @@ def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: s
cur.execute(
"""
INSERT INTO enrollment_sessions
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at)
VALUES (%s, %s, %s, %s, %s)
(id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at,
neuinstallation_ohne_rueckfrage)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at),
(session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at,
neuinstallation_ohne_rueckfrage),
)
return {"id": str(session_id)}
@@ -3748,10 +3765,11 @@ def compute_file_sha256(path) -> str:
return hasher.hexdigest()
def fetch_vorlage_label_for_session(session_id: str) -> str | None:
def fetch_vorlage_for_session(session_id: str) -> tuple[str | None, str | None]:
"""
Name der Bereitstellungsvorlage einer Enrollment Session - fuer den
Boot-Menue-Eintrag der Kunden-ISO (build_customer_iso.sh, VORLAGE_LABEL).
(Name der Bereitstellungsvorlage, Backend-Key) einer Enrollment Session -
fuer den Boot-Menue-Eintrag (VORLAGE_LABEL) und das Offline-Medium
(passendes Golden Image) in build_customer_iso.sh.
"""
try:
@@ -3759,19 +3777,40 @@ def fetch_vorlage_label_for_session(session_id: str) -> str | None:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.label
SELECT bv.label, b.key
FROM enrollment_sessions es
JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id
JOIN backends b ON b.id = bv.backend_id
WHERE es.id = %s
""",
(session_id,),
)
row = cur.fetchone()
except Exception:
# Rein kosmetisch - ein Fehler hier darf den ISO-Bau nie verhindern.
return None
# Rein unterstuetzend - ein Fehler hier darf den ISO-Bau nie verhindern.
return None, None
return row[0] if row else None
return (row[0], row[1]) if row else (None, None)
def golden_image_path_for_backend(backend_key: str | None) -> Path | None:
"""
Pfad des Golden Images eines Image-Backends. Der Dateiname steht nur an
einer Stelle - GOLDEN_IMAGE_FILENAME im jeweiligen backend.sh des
Installers -, deshalb wird er dort gelesen statt hier dupliziert.
"""
if not backend_key or not re.fullmatch(r"[a-z0-9-]+", backend_key):
return None
backend_file = Path(TUXFLOTTE_INSTALLER_DIR) / "backends" / backend_key / "backend.sh"
try:
match = re.search(r'^GOLDEN_IMAGE_FILENAME="([^"/]+)"', backend_file.read_text(), re.MULTILINE)
except OSError:
return None
if not match:
return None
path = GOLDEN_IMAGES_DIR / match.group(1)
return path if path.is_file() else None
ROOT_PASSWORD_HASH_PATTERN = re.compile(r"^\$6\$[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{86}$")
@@ -3780,6 +3819,7 @@ ROOT_PASSWORD_HASH_PATTERN = re.compile(r"^\$6\$[./A-Za-z0-9]{1,16}\$[./A-Za-z0-
def run_iso_build(
build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None,
root_password_hash: str | None = None,
offline_medium: bool = False,
) -> None:
output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso"
@@ -3787,7 +3827,18 @@ def run_iso_build(
organization = fetch_organization(organization_id)
volid = compute_iso_volid(organization["name"] if organization else "TUXFLOTTE")
vorlage_label = (fetch_vorlage_label_for_session(activation_code) or "").replace("\n", " ")
vorlage_label, backend_key = fetch_vorlage_for_session(activation_code)
vorlage_label = (vorlage_label or "").replace("\n", " ")
golden_image_file = ""
if offline_medium:
golden_image = golden_image_path_for_backend(backend_key)
if golden_image is None:
update_iso_build(
build_id, status="failed", mark_finished=True,
error_message=f"Offline-Medium: kein Golden Image für Backend '{backend_key}' gefunden.",
)
return
golden_image_file = str(golden_image)
try:
# De-Root-Umzug (07.09.2026): der Dienst selbst laeuft nicht mehr als
@@ -3801,7 +3852,7 @@ def run_iso_build(
"sudo",
"bash",
f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh",
MINT_ISO_PATH,
BOOT_MEDIUM_PATH,
str(output_path),
activation_code,
wifi_ssid or "",
@@ -3815,6 +3866,7 @@ def run_iso_build(
f"WIFI_PSK={wifi_psk or ''}\n"
f"ROOT_PASSWORD_HASH={root_password_hash or ''}\n"
f"VORLAGE_LABEL={vorlage_label}\n"
f"GOLDEN_IMAGE_FILE={golden_image_file}\n"
),
cwd=TUXFLOTTE_INSTALLER_DIR,
capture_output=True,
@@ -3983,6 +4035,13 @@ def activate(payload: ActivationRequest, request: Request):
"previously_installed": (
False if device_created else has_completed_installation(device["id"])
),
# Kunden-ISO-Test 03.10.2026: pro Installationsmedium abschaltbare
# Rueckfrage bei bereits installierten Geraeten (siehe
# 25_installation_confirm.sh) - fuer das bewusste Neuaufsetzen
# ganzer Raeume.
"reinstall_without_confirmation": bool(
enrollment_session and enrollment_session.get("neuinstallation_ohne_rueckfrage")
),
},
"customer": {
@@ -5022,7 +5081,8 @@ def create_enrollment_session_endpoint(
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
session = create_enrollment_session(
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at
organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at,
payload.neuinstallation_ohne_rueckfrage,
)
return {"success": True, "enrollment_session": session}
@@ -5095,7 +5155,7 @@ def create_iso_build_endpoint(
target=run_iso_build,
args=(
UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk,
payload.root_password_hash,
payload.root_password_hash, payload.offline_medium,
),
daemon=True,
)