Add browser-safe chunked ISO uploads with progress, partial-file visibility, offset validation, and abort cleanup while keeping the legacy multipart endpoint for API clients. Record host-log validation coverage, keep the queue/status UI copy clean, move release docs to 0.4.1, and tighten the dark theme to a near-black Netbox-style palette.
552 lines
20 KiB
Rust
552 lines
20 KiB
Rust
//! iPXE script generator.
|
|
//!
|
|
//! ## Menu hierarchy (per Phase 2 spec)
|
|
//!
|
|
//! ```text
|
|
//! Top level:
|
|
//! Default
|
|
//! > Boot from Local HDD
|
|
//! Installers
|
|
//! > Linux Installers -> submenu of Linux ISOs
|
|
//! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled)
|
|
//! Tools
|
|
//! > Utilities -> memtest, etc. (embedded assets only)
|
|
//! > OpenPXE Shell -> drop to iPXE shell with branded prompt
|
|
//! > Network Card Info -> ifstat / config / route dump
|
|
//! Queued Deployment -> join the deployment queue
|
|
//! ```
|
|
//!
|
|
//! ## iPXE is entirely backend — users do not see or write iPXE
|
|
//!
|
|
//! All user-facing knobs live in `Settings`. Script generation translates
|
|
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
|
|
//! There is intentionally no UI path to upload a custom `.ipxe` script.
|
|
|
|
use openpxe_core::{Settings, TimeoutAction};
|
|
use openpxe_iso_store::introspect::DistroFamily;
|
|
use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
|
|
use std::fmt::Write as _;
|
|
|
|
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
|
|
/// clients because iPXE normalises the menu primitives across firmwares.
|
|
#[must_use]
|
|
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
|
let mut s = String::new();
|
|
let base = base_url.trim_end_matches('/');
|
|
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
|
|
let default_item = match settings.timeout_action {
|
|
TimeoutAction::QueuedDeployment => "queue",
|
|
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
|
|
// label; the client waits for keypress. We use the same label as
|
|
// LocalHdd to keep the menu's pre-highlight stable.
|
|
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
|
|
};
|
|
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit");
|
|
let _ = writeln!(s, "set base-url {base}");
|
|
let _ = writeln!(s, "set esc:hex 1b");
|
|
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
|
|
let _ = writeln!(s, ":menu");
|
|
let _ = writeln!(s, "menu OpenPXE - network boot menu");
|
|
let _ = writeln!(
|
|
s,
|
|
"item --gap -- ------------------------- Default -------------------------"
|
|
);
|
|
let _ = writeln!(s, "item local Boot from Local HDD");
|
|
let _ = writeln!(
|
|
s,
|
|
"item --gap -- ----------------------- Installers -----------------------"
|
|
);
|
|
if has_family(isos, is_linux_family) {
|
|
let _ = writeln!(s, "item linux Linux Installers >");
|
|
} else {
|
|
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
|
}
|
|
if settings.windows_enabled && has_family(isos, is_windows_family) {
|
|
let _ = writeln!(s, "item windows Windows Installers >");
|
|
} else if settings.windows_enabled {
|
|
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
|
} else {
|
|
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
|
|
}
|
|
let _ = writeln!(
|
|
s,
|
|
"item --gap -- -------------------------- Tools --------------------------"
|
|
);
|
|
let _ = writeln!(s, "item tools Tools >");
|
|
let _ = writeln!(
|
|
s,
|
|
"item --gap -- ---------------------- Queued Deployment ---------------------"
|
|
);
|
|
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
|
|
let _ = writeln!(s, "item --gap");
|
|
let _ = writeln!(s, "item --key x exit Exit iPXE");
|
|
|
|
if matches!(settings.timeout_action, TimeoutAction::Stay) {
|
|
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
|
|
} else {
|
|
let _ = writeln!(
|
|
s,
|
|
"choose --default {default_item} --timeout {timeout_ms} target || goto menu"
|
|
);
|
|
}
|
|
|
|
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
|
|
// as the fallthrough target so the parser never sees a bare `||` with
|
|
// trailing whitespace — some iPXE builds reject that.
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} exit && exit || goto menu"
|
|
);
|
|
let _ = writeln!(s, "goto menu");
|
|
s
|
|
}
|
|
|
|
/// Per-family submenu (Linux or Windows). Each item shows the ISO size
|
|
/// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`).
|
|
#[must_use]
|
|
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let title = if is_windows {
|
|
"Windows Installers"
|
|
} else {
|
|
"Linux Installers"
|
|
};
|
|
let label = if is_windows { "windows" } else { "linux" };
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "set base-url {base}");
|
|
let _ = writeln!(s, ":menu");
|
|
let _ = writeln!(s, "menu OpenPXE - {title}");
|
|
let filter: fn(DistroFamily) -> bool = if is_windows {
|
|
is_windows_family
|
|
} else {
|
|
is_linux_family
|
|
};
|
|
let mut count = 0;
|
|
for iso in isos {
|
|
if !filter(iso.introspection.family) {
|
|
continue;
|
|
}
|
|
for entry in &iso.boot_entries {
|
|
let size_label = fmt_size_mib(iso.size_bytes);
|
|
let key = hotkey_for_index(count);
|
|
// Visual hint: a leading `*` marks password-protected entries.
|
|
// ASCII only — iPXE's menu console mangles non-ASCII on some
|
|
// firmwares.
|
|
let lock = if iso.is_password_protected() {
|
|
"*"
|
|
} else {
|
|
" "
|
|
};
|
|
let _ = writeln!(
|
|
s,
|
|
"item {}{} {}[{:>6}] {}",
|
|
key,
|
|
entry.id,
|
|
lock,
|
|
size_label,
|
|
escape_label(&entry.title),
|
|
);
|
|
count += 1;
|
|
}
|
|
}
|
|
if count == 0 {
|
|
let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)");
|
|
}
|
|
let _ = writeln!(s, "item --gap");
|
|
let _ = writeln!(s, "item --key b back < Back to main menu");
|
|
let _ = writeln!(s, "choose target || goto menu");
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
|
|
);
|
|
// Pass `?mac=${mac}` so the per-entry handler can record the booting
|
|
// client into the Host log. iPXE substitutes `${mac}` before
|
|
// the HTTP fetch; if the firmware can't resolve it the literal
|
|
// `${mac}` is sent and the server treats it as "unknown".
|
|
let _ = writeln!(
|
|
s,
|
|
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
|
|
);
|
|
s
|
|
}
|
|
|
|
/// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match
|
|
/// operator expectations from the original tool).
|
|
fn fmt_size_mib(bytes: u64) -> String {
|
|
let mib = bytes / (1024 * 1024);
|
|
format!("{mib} MB")
|
|
}
|
|
|
|
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
|
|
/// iPXE's menu needs the --key prefix as a separate token before the id.
|
|
fn hotkey_for_index(i: usize) -> String {
|
|
if i < 9 {
|
|
format!("--key {} ", i + 1)
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
|
|
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
|
|
#[must_use]
|
|
pub fn render_tools_menu(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "set base-url {base}");
|
|
let _ = writeln!(s, ":menu");
|
|
let _ = writeln!(s, "menu OpenPXE - Tools");
|
|
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
|
|
let _ = writeln!(s, "item --key s shell OpenPXE Shell");
|
|
let _ = writeln!(s, "item --key n nic Network Card Info");
|
|
let _ = writeln!(s, "item --gap");
|
|
let _ = writeln!(s, "item --key r reboot Reboot Computer");
|
|
let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot");
|
|
let _ = writeln!(s, "item --gap");
|
|
let _ = writeln!(s, "item --key b back < Back to main menu");
|
|
let _ = writeln!(s, "choose target || goto menu");
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} reboot && reboot || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} firmware && exit 0 || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(s, "goto menu");
|
|
s
|
|
}
|
|
|
|
/// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on
|
|
/// UEFI we `exit` so the firmware moves to the next boot entry (normally
|
|
/// the internal disk).
|
|
#[must_use]
|
|
pub fn render_local_hdd(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi"
|
|
);
|
|
let _ = writeln!(s, ":uefi");
|
|
let _ = writeln!(
|
|
s,
|
|
"# UEFI path: fall through to the firmware's next boot entry"
|
|
);
|
|
let _ = writeln!(s, "exit 0");
|
|
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
|
|
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
|
s
|
|
}
|
|
|
|
/// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional
|
|
/// asset (if absent, the item is listed but errors gracefully). No third-
|
|
/// party tools are fetched at runtime.
|
|
#[must_use]
|
|
pub fn render_util(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, ":menu");
|
|
let _ = writeln!(s, "menu OpenPXE - Utilities");
|
|
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
|
|
let _ = writeln!(s, "item --gap");
|
|
let _ = writeln!(s, "item back < Back");
|
|
let _ = writeln!(s, "choose target || goto menu");
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
|
|
);
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
|
|
);
|
|
let _ = writeln!(s, "goto menu");
|
|
s
|
|
}
|
|
|
|
/// "OpenPXE Shell" — iPXE shell, branded.
|
|
#[must_use]
|
|
pub fn render_shell(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "echo OpenPXE Shell");
|
|
let _ = writeln!(s, "echo 'exit' returns to the main menu");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "shell");
|
|
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
|
s
|
|
}
|
|
|
|
/// "Network Card Info" — print ifstat + route + config.
|
|
#[must_use]
|
|
pub fn render_nic_info(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "echo Network Card Info");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "ifstat");
|
|
let _ = writeln!(s, "echo");
|
|
let _ = writeln!(s, "route");
|
|
let _ = writeln!(s, "echo");
|
|
let _ = writeln!(s, "echo 'Press any key to return to menu'");
|
|
let _ = writeln!(s, "prompt --timeout 30000");
|
|
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
|
s
|
|
}
|
|
|
|
/// Queued Deployment entry point. Joins the queue, then enters a long-poll
|
|
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
|
|
/// real script comes back).
|
|
#[must_use]
|
|
pub fn render_queue_entry(base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(
|
|
s,
|
|
"# Queued Deployment - join the queue and wait for operator"
|
|
);
|
|
let _ = writeln!(s, "echo Joining deployment queue...");
|
|
// imgfetch writes the body to a file in iPXE's transient FS; we read
|
|
// the queue entry id out of the Location-style header by asking the server
|
|
// to put it in the response body as a single token.
|
|
let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}");
|
|
s
|
|
}
|
|
|
|
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
|
|
#[must_use]
|
|
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
|
|
let mut s = String::new();
|
|
let base = base_url.trim_end_matches('/');
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "set base-url {base}");
|
|
match &entry.kind {
|
|
BootKind::LinuxKernel {
|
|
kernel_url,
|
|
initrd_urls,
|
|
args,
|
|
} => {
|
|
let mut cmdline = args.cmdline.replace("${base-url}", base);
|
|
if !settings.extra_kernel_args.trim().is_empty() {
|
|
cmdline.push(' ');
|
|
cmdline.push_str(settings.extra_kernel_args.trim());
|
|
}
|
|
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
|
|
for u in initrd_urls {
|
|
let _ = writeln!(s, "initrd {base}/{u}");
|
|
}
|
|
let _ = writeln!(s, "boot || goto failed");
|
|
}
|
|
BootKind::Wimboot { wimboot_url, files } => {
|
|
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
|
|
for (tag, url) in files {
|
|
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
|
|
}
|
|
let _ = writeln!(s, "boot || goto failed");
|
|
}
|
|
BootKind::SanBootIso { iso_url } => {
|
|
let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed");
|
|
}
|
|
}
|
|
let _ = writeln!(s, ":failed");
|
|
let _ = writeln!(s, "echo Boot failed - returning to menu in 5s");
|
|
let _ = writeln!(s, "sleep 5");
|
|
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
|
s
|
|
}
|
|
|
|
fn is_linux_family(f: DistroFamily) -> bool {
|
|
matches!(
|
|
f,
|
|
DistroFamily::DebianUbuntu
|
|
| DistroFamily::RhelFedora
|
|
| DistroFamily::OpenSuse
|
|
| DistroFamily::Arch
|
|
| DistroFamily::Alpine
|
|
| DistroFamily::Unknown
|
|
)
|
|
}
|
|
|
|
fn is_windows_family(f: DistroFamily) -> bool {
|
|
matches!(f, DistroFamily::WindowsPe)
|
|
}
|
|
|
|
fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
|
|
isos.iter().any(|i| pred(i.introspection.family))
|
|
}
|
|
|
|
fn escape_label(s: &str) -> String {
|
|
s.chars()
|
|
.map(|c| match c {
|
|
'\n' | '\r' => ' ',
|
|
c => c,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Render the password-prompt script for a protected boot entry.
|
|
///
|
|
/// Flow on the client:
|
|
/// 1. iPXE clears any leftover ${password}, prints a banner naming the
|
|
/// ISO so the operator knows what they're being asked for.
|
|
/// 2. `read --secret password` accepts input without echoing it to
|
|
/// the screen.
|
|
/// 3. An empty input bails back to the main menu (lets the operator
|
|
/// back out of a misclick).
|
|
/// 4. Otherwise the script chains the same /boot/<id>.ipxe URL but
|
|
/// with `?token=${password:uristring}`. iPXE's `:uristring`
|
|
/// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc.
|
|
/// survive transport.
|
|
/// 5. The server replies with either the boot script (correct
|
|
/// password) or [`render_password_failed`] (wrong password). On
|
|
/// transport failure we fall back to the main menu.
|
|
#[must_use]
|
|
pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let label = escape_label(iso_filename);
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "# OpenPXE password prompt for {label}");
|
|
let _ = writeln!(s, "echo");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "echo This image requires a password");
|
|
let _ = writeln!(s, "echo {label}");
|
|
let _ = writeln!(s, "echo (enter alone returns to main menu)");
|
|
let _ = writeln!(s, "echo ==========================================");
|
|
let _ = writeln!(s, "set password ");
|
|
let _ = writeln!(s, "read --secret password");
|
|
let _ = writeln!(
|
|
s,
|
|
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
|
|
);
|
|
let _ = writeln!(s, ":submit");
|
|
let _ = writeln!(s, "echo Verifying...");
|
|
// Carry `mac=${mac}` alongside the token so a successful unlock
|
|
// records the actual client MAC into the Host log. On
|
|
// older iPXE that can't resolve `${mac}` the server just stores it
|
|
// as "unknown" rather than refusing to boot.
|
|
let _ = writeln!(
|
|
s,
|
|
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|
|
|| chain {base}/boot.ipxe"
|
|
);
|
|
s
|
|
}
|
|
|
|
/// Render the "wrong password" script. Tells the operator, sleeps for
|
|
/// two seconds (gives the eye time to register the message and dampens
|
|
/// brute-force rate without help from the server), and chains back to
|
|
/// the same entry — which sends them through the prompt flow again.
|
|
#[must_use]
|
|
pub fn render_password_failed(entry_id: &str, base_url: &str) -> String {
|
|
let base = base_url.trim_end_matches('/');
|
|
let mut s = String::new();
|
|
let _ = writeln!(s, "#!ipxe");
|
|
let _ = writeln!(s, "echo");
|
|
let _ = writeln!(s, "echo Wrong password.");
|
|
let _ = writeln!(s, "sleep 2");
|
|
let _ = writeln!(
|
|
s,
|
|
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
|
|
);
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod password_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn prompt_uses_secret_read_and_uri_escape() {
|
|
let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5");
|
|
assert!(s.starts_with("#!ipxe\n"));
|
|
assert!(s.contains("read --secret password"));
|
|
assert!(s.contains("Alpha Test.iso"));
|
|
// URI-string modifier on the var so passwords with `&`/spaces survive.
|
|
assert!(s.contains("token=${password:uristring}"));
|
|
// Empty enter sends back to the main menu, not back into the prompt
|
|
// (avoids a wedged client if the operator chose by mistake).
|
|
assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit"));
|
|
// Never log/echo the value.
|
|
assert!(!s.contains("echo ${password"));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_chains_back_to_entry() {
|
|
let s = render_password_failed("alpha-linux", "http://10.0.0.5");
|
|
assert!(s.contains("Wrong password."));
|
|
// Re-target the entry so the prompt flow runs again.
|
|
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
|
|
}
|
|
|
|
#[test]
|
|
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
|
let settings = Settings::default();
|
|
let scripts = [
|
|
render_menu(&[], &settings, "http://10.0.0.5"),
|
|
render_tools_menu("http://10.0.0.5"),
|
|
render_local_hdd("http://10.0.0.5"),
|
|
render_util("http://10.0.0.5"),
|
|
render_shell("http://10.0.0.5"),
|
|
render_nic_info("http://10.0.0.5"),
|
|
render_queue_entry("http://10.0.0.5"),
|
|
render_password_failed("alpha-linux", "http://10.0.0.5"),
|
|
];
|
|
for script in scripts {
|
|
for line in script.lines() {
|
|
assert!(
|
|
!line.trim_end().ends_with("||"),
|
|
"bare iPXE fallback operator in line: {line}\nscript:\n{script}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|