Improve installer VM compatibility

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
This commit is contained in:
naturallaw777
2026-08-04 19:07:51 +00:00
co-authored by arena-agent
parent f39f59fb81
commit 5188cca9aa
4 changed files with 303 additions and 43 deletions
+1
View File
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's - **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
"What gets backed up" list no longer shows Node / Server + Desktop items "What gets backed up" list no longer shows Node / Server + Desktop items
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain (nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
+20
View File
@@ -397,6 +397,26 @@ installer. Do not format the drive.
If the normal operating system starts instead, restart and try the boot-menu If the normal operating system starts instead, restart and try the boot-menu
key again. key again.
### Installing in a virtual machine (optional)
Sovran_SystemsOS can be tested in VirtualBox, VMware, QEMU/KVM, Proxmox, and
similar x86_64 virtual machines. Use production-like resources where possible:
- Allocate **8 GB RAM or more** for Desktop Only. Node and Server + Desktop
should follow the normal 16 GB / 32 GB recommendations.
- Create a **256 GB or larger virtual OS disk**. Thin-provisioned / dynamically
allocated disks are fine; they do not consume the full size immediately.
- Use NAT or bridged networking with internet access before opening the
installer.
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
the installer automatically switches the installed system to GRUB.
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
If you only attach one disk, choose **Desktop Only**.
- Present the install target as a normal virtual disk such as VirtIO, SATA,
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
installer to avoid erasing the installer USB by mistake.
### 5. Install ### 5. Install
Follow the on-screen installer. Before confirming: Follow the on-screen installer. Before confirming:
+186 -23
View File
@@ -103,25 +103,81 @@ def human_size(nbytes):
return f"{nbytes:.1f} PB" return f"{nbytes:.1f} PB"
def check_internet(): def check_internet():
"""Return True if the machine can reach the internet.""" """Return True if the machine can reach the internet.
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
so keep the existing ping checks but fall back to small HTTPS requests.
"""
checks = [
["ping", "-c", "1", "-W", "5", "nixos.org"],
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
]
for cmd in checks:
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return True
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
except Exception as e:
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
return False
def detect_virtualization():
"""Return the VM technology name when running inside a VM, else None."""
try: try:
result = subprocess.run( result = subprocess.run(
["ping", "-c", "1", "-W", "5", "nixos.org"], ["systemd-detect-virt", "--vm"],
capture_output=True, text=True capture_output=True, text=True
) )
if result.returncode == 0: if result.returncode == 0:
return True name = result.stdout.strip()
if name == "none":
return None
return name or "virtual machine"
except Exception: except Exception:
pass pass
# Fallback: try a second host in case DNS for nixos.org is down
try: # Fallback for minimal environments where systemd-detect-virt is absent.
result = subprocess.run( dmi_paths = [
["ping", "-c", "1", "-W", "5", "1.1.1.1"], "/sys/class/dmi/id/product_name",
capture_output=True, text=True "/sys/class/dmi/id/sys_vendor",
"/sys/class/dmi/id/board_vendor",
]
markers = (
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
"bhyve", "parallels", "xen", "bochs", "virtual",
) )
return result.returncode == 0 for dmi_path in dmi_paths:
except Exception: try:
return False value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
except OSError:
continue
lowered = value.lower()
if any(marker in lowered for marker in markers):
return value or "virtual machine"
return None
def detect_boot_mode():
"""Return the firmware mode used to boot the installer ISO."""
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
def boot_mode_label(mode):
if mode == "uefi":
return "UEFI (systemd-boot)"
return "Legacy BIOS (GRUB)"
def nix_string(value):
"""Quote a Python string for a simple Nix string literal."""
escaped = value.replace("\\", "\\\\")
escaped = escaped.replace('"', '\\"')
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
escaped = escaped.replace("${", "\\${")
return f'"{escaped}"'
def symbolic_icon(name): def symbolic_icon(name):
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix.""" """Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
@@ -159,6 +215,10 @@ class InstallerWindow(Adw.ApplicationWindow):
self.data_size = None self.data_size = None
self.data_drive_has_timechain = False self.data_drive_has_timechain = False
self.free_password = None self.free_password = None
self.virtualization = detect_virtualization()
self.boot_mode = detect_boot_mode()
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
# Root navigation view # Root navigation view
self.nav = Adw.NavigationView() self.nav = Adw.NavigationView()
@@ -343,7 +403,7 @@ class InstallerWindow(Adw.ApplicationWindow):
if os.path.exists(LOGO): if os.path.exists(LOGO):
try: try:
img = Gtk.Image.new_from_file(LOGO) img = Gtk.Image.new_from_file(LOGO)
img.set_pixel_size(480) img.set_pixel_size(320 if self.virtualization else 480)
hero.append(img) hero.append(img)
except Exception: except Exception:
pass pass
@@ -397,6 +457,42 @@ class InstallerWindow(Adw.ApplicationWindow):
notice_frame.set_child(notice_box) notice_frame.set_child(notice_box)
outer.append(notice_frame) outer.append(notice_frame)
if self.virtualization:
vm_frame = Gtk.Frame()
vm_frame.add_css_class("card")
vm_frame.set_margin_start(40)
vm_frame.set_margin_end(40)
vm_frame.set_margin_top(12)
vm_frame.set_margin_bottom(4)
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
vm_box.set_margin_top(12)
vm_box.set_margin_bottom(12)
vm_box.set_margin_start(16)
vm_box.set_margin_end(16)
vm_icon = symbolic_icon("computer-symbolic")
vm_icon.set_valign(Gtk.Align.START)
vm_box.append(vm_icon)
vm_lbl = Gtk.Label()
vm_lbl.set_use_markup(True)
vm_lbl.set_wrap(True)
vm_lbl.set_xalign(0)
vm_lbl.set_halign(Gtk.Align.FILL)
vm_lbl.set_markup(
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
"and a thin-provisioned virtual OS disk of at least 256 GB. "
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
"otherwise choose Desktop Only."
)
vm_box.append(vm_lbl)
vm_frame.set_child(vm_box)
outer.append(vm_frame)
# Role label # Role label
role_lbl = Gtk.Label() role_lbl = Gtk.Label()
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>") role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
@@ -448,7 +544,10 @@ class InstallerWindow(Adw.ApplicationWindow):
available = has_second_drive or key not in NEEDS_DATA_DRIVE available = has_second_drive or key not in NEEDS_DATA_DRIVE
if not available: if not available:
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)") drive_hint = "⚠ Requires a second internal drive (not detected)"
if self.virtualization:
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
card.set_subtitle(desc + f"\n{drive_hint}")
card.set_sensitive(False) card.set_sensitive(False)
else: else:
card.set_subtitle(desc) card.set_subtitle(desc)
@@ -531,9 +630,10 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── OS Drive group ──────────────────────────────────────────── # ── OS Drive group ────────────────────────────────────────────
os_group = Adw.PreferencesGroup() os_group = Adw.PreferencesGroup()
os_group.set_title("OS Drive (NixOS Boot + Root)") os_group.set_title("OS Drive (NixOS Boot + Root)")
os_group.set_description( os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
"Choose the drive for the NixOS installation. Minimum 256 GB required." if self.virtualization:
) os_description += " In a VM, a thin-provisioned virtual disk is OK."
os_group.set_description(os_description)
os_group.set_margin_top(24) os_group.set_margin_top(24)
os_group.set_margin_start(40) os_group.set_margin_start(40)
os_group.set_margin_end(40) os_group.set_margin_end(40)
@@ -546,6 +646,8 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}") row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk" type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum" meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
if self.virtualization and size < BYTES_256GB:
meets += " (expand the virtual disk)"
row.set_subtitle(f"{human_size(size)} · {type_label}{meets}") row.set_subtitle(f"{human_size(size)} · {type_label}{meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -570,11 +672,14 @@ class InstallerWindow(Adw.ApplicationWindow):
if self.role != "Desktop Only": if self.role != "Desktop Only":
data_group = Adw.PreferencesGroup() data_group = Adw.PreferencesGroup()
data_group.set_title("Bitcoin Timechain & Backups Drive") data_group.set_title("Bitcoin Timechain & Backups Drive")
data_group.set_description( data_description = (
"💡 Tip: Always assign your LARGEST drive here. " "💡 Tip: Always assign your LARGEST drive here. "
"The full Bitcoin timechain is over 700 GB and grows continuously — " "The full Bitcoin timechain is over 700 GB and grows continuously — "
"a 2 TB or larger drive is required." "a 2 TB or larger drive is required."
) )
if self.virtualization:
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
data_group.set_description(data_description)
data_group.set_margin_top(20) data_group.set_margin_top(20)
data_group.set_margin_start(40) data_group.set_margin_start(40)
data_group.set_margin_end(40) data_group.set_margin_end(40)
@@ -599,6 +704,8 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}") row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk" type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum" meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
if self.virtualization and size < BYTES_2TB:
meets += " (use a 2 TB thin-provisioned virtual disk)"
row.set_subtitle(f"{human_size(size)} · {type_label}{meets}") row.set_subtitle(f"{human_size(size)} · {type_label}{meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -655,10 +762,16 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog() dlg = Adw.MessageDialog()
dlg.set_transient_for(self) dlg.set_transient_for(self)
dlg.set_heading("OS Drive Too Small") dlg.set_heading("OS Drive Too Small")
dlg.set_body( body = (
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) " f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
f"does not meet the 256 GB minimum. Please choose a larger drive." f"does not meet the 256 GB minimum. Please choose a larger drive."
) )
if self.virtualization:
body += (
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
"Thin-provisioned disks usually do not consume the full size immediately."
)
dlg.set_body(body)
dlg.add_response("ok", "OK") dlg.add_response("ok", "OK")
dlg.present() dlg.present()
return return
@@ -668,12 +781,18 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog() dlg = Adw.MessageDialog()
dlg.set_transient_for(self) dlg.set_transient_for(self)
dlg.set_heading("Bitcoin Drive Too Small") dlg.set_heading("Bitcoin Drive Too Small")
dlg.set_body( body = (
f"The selected Bitcoin Timechain & Backups drive " f"The selected Bitcoin Timechain & Backups drive "
f"(/dev/{data_name}, {human_size(data_size)}) " f"(/dev/{data_name}, {human_size(data_size)}) "
f"does not meet the 2 TB minimum. " f"does not meet the 2 TB minimum. "
f"Please choose a larger drive or select \"None\"." f"Please choose a larger drive or select \"None\"."
) )
if self.virtualization:
body += (
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
"for Node or Server + Desktop test installs."
)
dlg.set_body(body)
dlg.add_response("ok", "OK") dlg.add_response("ok", "OK")
dlg.present() dlg.present()
return return
@@ -829,7 +948,12 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Worker: partition ───────────────────────────────────────────────── # ── Worker: partition ─────────────────────────────────────────────────
def partition_path(self, dev_path, num): def partition_path(self, dev_path, num):
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}" base = os.path.basename(dev_path)
needs_p = (
base.startswith(("nvme", "mmcblk", "loop", "md")) or
(base[-1:].isdigit())
)
return f"{dev_path}p{num}" if needs_p else f"{dev_path}{num}"
def detect_existing_timechain_data(self, data_path, buf=None): def detect_existing_timechain_data(self, data_path, buf=None):
data_p1 = self.partition_path(data_path, 1) data_p1 = self.partition_path(data_path, 1)
@@ -900,12 +1024,19 @@ class InstallerWindow(Adw.ApplicationWindow):
time.sleep(2) time.sleep(2)
# ── Partition boot disk: 512M ESP + rest as root ── # ── Partition boot disk ──
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n") if self.boot_mode == "uefi":
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
run_stream(["sudo", "sgdisk", run_stream(["sudo", "sgdisk",
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP", "-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root", "-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
boot_path], buf) boot_path], buf)
else:
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
run_stream(["sudo", "sgdisk",
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
boot_path], buf)
run_stream(["sudo", "partprobe", boot_path], buf) run_stream(["sudo", "partprobe", boot_path], buf)
time.sleep(2) time.sleep(2)
@@ -925,7 +1056,10 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_p1 = self.partition_path(boot_path, 1) boot_p1 = self.partition_path(boot_path, 1)
boot_p2 = self.partition_path(boot_path, 2) boot_p2 = self.partition_path(boot_path, 2)
if self.boot_mode == "uefi":
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf) run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
else:
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf) run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
if data_path and not self.data_drive_has_timechain: if data_path and not self.data_drive_has_timechain:
@@ -935,8 +1069,11 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Mount filesystems ── # ── Mount filesystems ──
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n") GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf) run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
if self.boot_mode == "uefi":
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf) run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf) run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
else:
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
if data_path: if data_path:
data_p1 = self.partition_path(data_path, 1) data_p1 = self.partition_path(data_path, 1)
@@ -966,12 +1103,33 @@ class InstallerWindow(Adw.ApplicationWindow):
is_server = str(self.role == "Server+Desktop").lower() is_server = str(self.role == "Server+Desktop").lower()
is_desktop = str(self.role == "Desktop Only").lower() is_desktop = str(self.role == "Desktop Only").lower()
is_node = str(self.role == "Node (Bitcoin-only)").lower() is_node = str(self.role == "Node (Bitcoin-only)").lower()
boot_overrides = ""
if self.boot_mode == "bios":
grub_device = nix_string(f"/dev/{self.boot_disk}")
boot_overrides = f"""
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
# target disk instead of using the default UEFI systemd-boot configuration.
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub.enable = lib.mkForce true;
boot.loader.grub.device = lib.mkForce {grub_device};
fileSystems."/boot/efi".enable = lib.mkForce false;
"""
elif self.virtualization:
boot_overrides = """
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
# normal UEFI systemd-boot layout, but use the fallback removable path instead
# of depending on EFI variable updates.
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
"""
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT. content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
{{ config, lib, ... }}: {{ config, lib, ... }}:
{{ {{
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server}; sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop}; sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
sovran_systemsOS.roles.node = lib.mkDefault {is_node}; sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
}} }}
""" """
proc = subprocess.run( proc = subprocess.run(
@@ -1008,6 +1166,11 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}") boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}")
details.add(boot_row) details.add(boot_row)
boot_mode_row = Adw.ActionRow()
boot_mode_row.set_title("Boot Mode")
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
details.add(boot_mode_row)
if self.data_disk: if self.data_disk:
data_row = Adw.ActionRow() data_row = Adw.ActionRow()
data_row.set_title("Data Disk") data_row.set_title("Data Disk")
+81 -5
View File
@@ -12,6 +12,11 @@ Options:
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install --deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
--headscale-server URL Headscale login server for post-install Tailnet --headscale-server URL Headscale login server for post-install Tailnet
--headscale-key KEY Headscale pre-auth key for the installed OS --headscale-key KEY Headscale pre-auth key for the installed OS
VM notes:
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
USAGE USAGE
} }
@@ -25,6 +30,8 @@ DEPLOY_KEY=""
HEADSCALE_SERVER="" HEADSCALE_SERVER=""
HEADSCALE_KEY="" HEADSCALE_KEY=""
DATA_DISK_HAS_TIMECHAIN=false DATA_DISK_HAS_TIMECHAIN=false
BOOT_MODE=""
VIRTUALIZATION=""
FLAKE="/etc/sovran/flake" FLAKE="/etc/sovran/flake"
LOG="/tmp/sovran-headless-install.log" LOG="/tmp/sovran-headless-install.log"
@@ -69,6 +76,22 @@ case "$ROLE" in
*) die "--role must be one of: server, desktop, node" ;; *) die "--role must be one of: server, desktop, node" ;;
esac esac
# ── Detect firmware / VM environment ─────────────────────────────────────────
if [[ -d /sys/firmware/efi ]]; then
BOOT_MODE="uefi"
else
BOOT_MODE="bios"
fi
if command -v systemd-detect-virt >/dev/null 2>&1; then
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
fi
fi
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
# ── Validate disk existence and size ───────────────────────────────────────── # ── Validate disk existence and size ─────────────────────────────────────────
log "=== Validating disks ===" log "=== Validating disks ==="
@@ -96,13 +119,26 @@ fi
# ── Helper: partition suffix ────────────────────────────────────────────────── # ── Helper: partition suffix ──────────────────────────────────────────────────
part_suffix() { part_suffix() {
local dev="$1" n="$2" local dev="$1" n="$2"
if [[ "$dev" == *nvme* ]]; then local base
base=$(basename "$dev")
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
echo "${dev}p${n}" echo "${dev}p${n}"
else else
echo "${dev}${n}" echo "${dev}${n}"
fi fi
} }
nix_string() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/\\r}"
value="${value//$'\t'/\\t}"
value="${value//\$\{/\\\$\{}"
printf '"%s"' "$value"
}
# ── Detect existing Bitcoin timechain data on data disk ─────────────────────── # ── Detect existing Bitcoin timechain data on data disk ───────────────────────
if [[ -n "$DATA_DISK" ]]; then if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1) DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -142,12 +178,19 @@ partprobe "$DISK"
sleep 2 sleep 2
# ── Step 2: Partition OS disk ───────────────────────────────────────────────── # ── Step 2: Partition OS disk ─────────────────────────────────────────────────
log "=== Partitioning OS disk ===" if [[ "$BOOT_MODE" == "uefi" ]]; then
log "=== Partitioning OS disk (UEFI) ==="
sgdisk \ sgdisk \
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \ -n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
-n "2:0:0" -t "2:8300" -c "2:root" \ -n "2:0:0" -t "2:8300" -c "2:root" \
"$DISK" "$DISK"
else
log "=== Partitioning OS disk (Legacy BIOS) ==="
sgdisk \
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
-n "2:0:0" -t "2:8300" -c "2:root" \
"$DISK"
fi
partprobe "$DISK" partprobe "$DISK"
sleep 2 sleep 2
@@ -168,7 +211,11 @@ log "=== Formatting partitions ==="
BOOT_P1=$(part_suffix "$DISK" 1) BOOT_P1=$(part_suffix "$DISK" 1)
BOOT_P2=$(part_suffix "$DISK" 2) BOOT_P2=$(part_suffix "$DISK" 2)
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkfs.vfat -F 32 "$BOOT_P1" mkfs.vfat -F 32 "$BOOT_P1"
else
log "Skipping ESP format for Legacy BIOS install"
fi
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2" mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
@@ -180,8 +227,12 @@ fi
log "=== Mounting filesystems ===" log "=== Mounting filesystems ==="
mount "$BOOT_P2" /mnt mount "$BOOT_P2" /mnt
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkdir -p /mnt/boot/efi mkdir -p /mnt/boot/efi
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
else
log "Legacy BIOS install: /boot/efi mount not required"
fi
if [[ -n "$DATA_DISK" ]]; then if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1) DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -219,16 +270,41 @@ case "$ROLE" in
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;; IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
esac esac
cat > /mnt/etc/nixos/role-state.nix <<EOF {
cat <<EOF
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT. # THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
{ config, lib, ... }: { config, lib, ... }:
{ {
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER}; sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP}; sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE}; sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
}
EOF EOF
if [[ "$BOOT_MODE" == "bios" ]]; then
GRUB_DEVICE=$(nix_string "$DISK")
cat <<EOF
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
# target disk instead of using the default UEFI systemd-boot configuration.
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub.enable = lib.mkForce true;
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
fileSystems."/boot/efi".enable = lib.mkForce false;
EOF
elif [[ -n "$VIRTUALIZATION" ]]; then
cat <<EOF
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
# normal UEFI systemd-boot layout, but use the fallback removable path instead
# of depending on EFI variable updates.
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
EOF
fi
echo "}"
} > /mnt/etc/nixos/role-state.nix
# ── Step 10: Write custom.nix with deploy config ────────────────────────────── # ── Step 10: Write custom.nix with deploy config ──────────────────────────────
log "=== Writing custom.nix ===" log "=== Writing custom.nix ==="