From 45b56b3e61bdaeffee5e389c529987cbeb373140 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 20:21:44 +0200 Subject: [PATCH] Split the Pi flasher and the boot-repair test harness raspberry_pi_flash_sd.sh 661 -> 166 and test_boot_repair.sh 315 -> 104. Heredoc positions were checked before choosing seams this time. test_boot_repair.sh is the first linux_configuration script in this effort that could be verified by running it: every case builds a throwaway root in a temp dir and passes --root. 31 passed after the split, and 31 passed from a detached worktree at HEAD - a real baseline comparison rather than trusting the post-split number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../linux-config-batch9-2026-08-16.json | 18 + .../linux-config-batch9-2026-08-16.json | 42 ++ .../tests/lib/boot_repair_cases.sh | 221 ++++++++ .../boot_recovery/tests/test_boot_repair.sh | 215 +------- .../single_use/features/lib/pi_flash_exec.sh | 187 +++++++ .../single_use/features/lib/pi_flash_image.sh | 193 +++++++ .../features/lib/pi_flash_remote.sh | 141 +++++ .../features/raspberry_pi_flash_sd.sh | 507 +----------------- 8 files changed, 810 insertions(+), 714 deletions(-) create mode 100644 docs/superpowers/contracts/linux-config-batch9-2026-08-16.json create mode 100644 docs/superpowers/evidence/linux-config-batch9-2026-08-16.json create mode 100755 linux_configuration/scripts/boot_recovery/tests/lib/boot_repair_cases.sh create mode 100755 linux_configuration/scripts/single_use/features/lib/pi_flash_exec.sh create mode 100755 linux_configuration/scripts/single_use/features/lib/pi_flash_image.sh create mode 100755 linux_configuration/scripts/single_use/features/lib/pi_flash_remote.sh diff --git a/docs/superpowers/contracts/linux-config-batch9-2026-08-16.json b/docs/superpowers/contracts/linux-config-batch9-2026-08-16.json new file mode 100644 index 00000000..8e167909 --- /dev/null +++ b/docs/superpowers/contracts/linux-config-batch9-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Split raspberry_pi_flash_sd.sh and test_boot_repair.sh under the cap", + "objective": "Bring raspberry_pi_flash_sd.sh (661) and test_boot_repair.sh (315) under the 250-line cap, choosing seams that avoid heredocs after the enforce_vbox_hosts failure.", + "acceptance_criteria": [ + "Both entry scripts and all four new libs are at most 250 lines.", + "The pre-commit shellcheck hook passes on every touched file, with no new suppression and no disable directive.", + "No lib sources a sibling; every lib parses under bash -n.", + "Every seam is chosen outside a heredoc: the heredoc line numbers are listed before picking cut points.", + "test_boot_repair.sh is a sandboxed fixture harness, so it MUST be run: it has to report the same pass count after the split as a clean worktree at HEAD reports before it.", + "raspberry_pi_flash_sd.sh is not executed - it writes partition tables to an SD card." + ], + "out_of_scope": [ + "Executing raspberry_pi_flash_sd.sh or any flashing step.", + "The seven deferred shell scripts.", + "Changing what any script does when run." + ], + "verifier": "meta/scripts/verify_shell_split.sh for both, plus running test_boot_repair.sh and comparing its pass count against the same script run from a detached worktree at HEAD, plus pre-commit run --files and check_file_length.sh." +} diff --git a/docs/superpowers/evidence/linux-config-batch9-2026-08-16.json b/docs/superpowers/evidence/linux-config-batch9-2026-08-16.json new file mode 100644 index 00000000..0965f729 --- /dev/null +++ b/docs/superpowers/evidence/linux-config-batch9-2026-08-16.json @@ -0,0 +1,42 @@ +{ + "intent": "Split two more linux_configuration scripts, one of which could finally be verified by actually running it.", + "scope": [ + "single_use/features/raspberry_pi_flash_sd.sh (661 -> 166) + pi_flash_remote, pi_flash_image, pi_flash_exec", + "boot_recovery/tests/test_boot_repair.sh (315 -> 104) + boot_repair_cases.sh", + "Non-goal: raspberry_pi_flash_sd.sh not executed" + ], + "changes": [ + "Both scripts were checked for heredoc positions BEFORE choosing seams, after the enforce_vbox_hosts split cut inside one. raspberry_pi_flash_sd.sh has heredocs at lines 68 and 609; the three cut points (134, 273, 460) sit clear of both.", + "test_boot_repair.sh's fixtures and repair cases moved to boot_repair_cases.sh, leaving the assertions and the runner in the entry." + ], + "verification": [ + { + "command": "bash linux_configuration/scripts/boot_recovery/tests/test_boot_repair.sh", + "result": "pass", + "evidence": "31 passed, 0 failed. This is the first linux_configuration script in the whole effort that could be RUN as verification rather than statically checked: its header documents that every case builds a throwaway root in a temp dir and passes --root, so nothing on the machine is touched." + }, + { + "command": "same script from a detached worktree at HEAD", + "result": "pass", + "evidence": "31 passed, 0 failed - the identical count, so the split neither lost nor duplicated a case. Establishing the baseline from a clean worktree rather than trusting the post-split number is the shell equivalent of the pass-count rule used throughout python_pkg." + }, + { + "command": "meta/scripts/verify_shell_split.sh for both scripts", + "result": "pass", + "evidence": "Both report 'libs sourced, functions defined' on the first attempt - no chained libs, no SCRIPT_DIR collision, no heredoc cut." + }, + { + "command": "pre-commit run --files <6 changed files>", + "result": "pass", + "evidence": "Clean, including shellcheck over each lib in isolation." + } + ], + "risks": [ + "raspberry_pi_flash_sd.sh writes partition tables to an SD card and was not run; only the stubbed run and the static checks cover it.", + "Checking heredoc positions before choosing a seam is currently a manual step. The verifier catches the failure afterwards via bash -n, but the splitter still has no heredoc awareness." + ], + "rollback": [ + "git revert the commit; both scripts return to 661 and 315 lines and the four libs disappear.", + "After rollback, re-run test_boot_repair.sh and expect 31 passed." + ] +} diff --git a/linux_configuration/scripts/boot_recovery/tests/lib/boot_repair_cases.sh b/linux_configuration/scripts/boot_recovery/tests/lib/boot_repair_cases.sh new file mode 100755 index 00000000..c7021994 --- /dev/null +++ b/linux_configuration/scripts/boot_recovery/tests/lib/boot_repair_cases.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Test fixtures and the individual repair cases. +# +# Sourced by test_boot_repair.sh; split out to keep it under the 250-line +# cap. Sourced rather than run, so it inherits the caller's strict mode +# and the variables defined above the source line. + +# ---------------------------------------------------------------------------- +# Fixture construction +# ---------------------------------------------------------------------------- + +# make_root -> prints the path to a fresh fixture root +# +# Builds a consistent system: one complete kernel tree, an ESP whose kernel +# and initramfs match it, a gating fstab and serial downloads. +make_root() { + local name="$1" + local r="$WORKROOT/$name" + local kver="${2:-7.1.5-arch1-2}" + + mkdir -p "$r/usr/lib/modules/$kver/kernel/fs/fat" + mkdir -p "$r/boot" "$r/etc" "$r/var/cache/pacman/pkg" + + # Fake kernel image carrying the version banner boot-repair greps for. + printf 'Linux version %s (linux@archlinux) fake image\n' "$kver" \ + >"$r/usr/lib/modules/$kver/vmlinuz" + : >"$r/usr/lib/modules/$kver/modules.dep" + : >"$r/usr/lib/modules/$kver/kernel/fs/fat/vfat.ko.zst" + + # ESP contents, consistent with the kernel tree. + cp "$r/usr/lib/modules/$kver/vmlinuz" "$r/boot/vmlinuz-linux" + printf 'usr/lib/modules/%s/kernel/fs/fat/vfat.ko.zst\n' "$kver" \ + >"$r/boot/initramfs-linux.img" + mkdir -p "$r/boot/EFI" "$r/boot/loader" + + printf 'UUID=DEAD-BEEF\t/boot\tvfat\tdefaults\t0 2\n' >"$r/etc/fstab" + printf 'ParallelDownloads = 1\n' >"$r/etc/pacman.conf" + + printf '%s' "$r" +} + +run_repair() { + "$BOOT_REPAIR" --root "$1" "${@:2}" 2>&1 +} + +# ---------------------------------------------------------------------------- +# Tests +# ---------------------------------------------------------------------------- + +test_healthy_system() { + echo "TEST: healthy system reports consistent" + local r out rc + r="$(make_root healthy)" + out="$(run_repair "$r" --dry-run)" + rc=$? + assert_eq "exit status is 0" "$rc" "0" + assert_contains "reports consistent" "$out" "nothing to repair" + assert_contains "picks the right kernel" "$out" "target kernel: 7.1.5-arch1-2" +} + +test_stale_esp_kernel_detected() { + echo "TEST: stale ESP kernel is detected and repaired" + local r out rc + r="$(make_root stale)" + # Simulate the real incident: ESP still holds the previous kernel. + printf 'Linux version 7.0.5-arch1-1 (linux@archlinux) fake image\n' \ + >"$r/boot/vmlinuz-linux" + + out="$(run_repair "$r" --dry-run)" + rc=$? + assert_eq "dry-run exits 1" "$rc" "1" + assert_contains "detects the mismatch" "$out" "ESP kernel is 7.0.5-arch1-1 but installed tree is 7.1.5-arch1-2" + assert_contains "dry-run changes nothing" "$out" "would install" + + # The ESP must be untouched by a dry run. + assert_contains "dry-run left ESP alone" "$(cat "$r/boot/vmlinuz-linux")" "7.0.5-arch1-1" + + out="$(run_repair "$r")" + assert_contains "repairs the kernel image" "$out" "installed kernel 7.1.5-arch1-2 to the ESP" + assert_contains "ESP now has the new kernel" "$(cat "$r/boot/vmlinuz-linux")" "7.1.5-arch1-2" +} + +test_incomplete_tree_ignored() { + echo "TEST: a gutted module tree is not chosen as the target" + local r out + r="$(make_root gutted)" + # The exact wreckage the failed upgrade leaves: metadata only, no kernel/, + # no vmlinuz. It sorts newer, so a naive 'newest' pick would take it. + mkdir -p "$r/usr/lib/modules/9.9.9-arch1-1" + : >"$r/usr/lib/modules/9.9.9-arch1-1/modules.dep" + : >"$r/usr/lib/modules/9.9.9-arch1-1/modules.alias" + + out="$(run_repair "$r" --dry-run)" + assert_contains "ignores the incomplete tree" "$out" "target kernel: 7.1.5-arch1-2" + assert_not_contains "does not pick 9.9.9" "$out" "target kernel: 9.9.9-arch1-1" +} + +test_picks_newest_complete() { + echo "TEST: newest COMPLETE tree wins" + local r out kver + r="$(make_root multi)" + kver="7.2.0-arch1-1" + mkdir -p "$r/usr/lib/modules/$kver/kernel/fs/fat" + printf 'Linux version %s (linux@archlinux) fake image\n' "$kver" \ + >"$r/usr/lib/modules/$kver/vmlinuz" + : >"$r/usr/lib/modules/$kver/modules.dep" + + out="$(run_repair "$r" --dry-run)" + assert_contains "selects the newer complete tree" "$out" "target kernel: 7.2.0-arch1-1" +} + +test_fstab_nofail_fixed() { + echo "TEST: noauto/nofail on /boot is detected and reset" + local r out + r="$(make_root fstab)" + printf 'UUID=DEAD-BEEF\t/boot\tvfat\tdefaults,noauto,nofail\t0 2\n' >"$r/etc/fstab" + + out="$(run_repair "$r" --dry-run)" + assert_contains "detects the silent-failure options" "$out" "noauto/nofail" + + out="$(run_repair "$r")" + assert_contains "resets the options" "$out" "reset to 'defaults'" + assert_contains "fstab now gates" "$(cat "$r/etc/fstab")" "defaults" + assert_not_contains "nofail is gone" "$(cat "$r/etc/fstab")" "nofail" + # A backup must exist so the change is reversible. + if compgen -G "$r/etc/fstab.bak-*" >/dev/null; then + pass "fstab backup written" + else + fail "fstab backup written" + fi +} + +test_shadow_files_removed() { + echo "TEST: shadow kernel files in an unmounted /boot are removed" + local r out + r="$(make_root shadow)" + # An unmounted /boot has no EFI/ or loader/ — that is what makes the files + # below shadows on the root filesystem rather than the real ESP. + rm -rf "${r:?}/boot" + mkdir -p "$r/boot" + printf 'Linux version 7.1.5-arch1-2 (linux@archlinux) fake\n' >"$r/boot/vmlinuz-linux" + printf 'usr/lib/modules/7.1.5-arch1-2/x\n' >"$r/boot/initramfs-linux.img" + printf 'stale\n' >"$r/boot/initramfs-linux-fallback.img" + + out="$(run_repair "$r")" + assert_contains "detects the shadows" "$out" "orphaned kernel file" + assert_contains "removes them" "$out" "removed 3 shadow file" + # The fallback image is not regenerated by a --root run, so its absence + # proves the deletion happened. (vmlinuz-linux legitimately reappears: + # sync_kernel_image reinstalls the target kernel straight afterwards.) + if [[ ! -f $r/boot/initramfs-linux-fallback.img ]]; then + pass "shadow fallback image deleted" + else + fail "shadow fallback image deleted" + fi +} + +test_shadow_guard_refuses_real_esp() { + echo "TEST: SAFETY — never delete from a directory that looks like a real ESP" + local r out + r="$(make_root guarded)" + # EFI/ and loader/ are present (make_root creates them), so even though the + # fixture is "unmounted", boot-repair must refuse to delete anything. + out="$(run_repair "$r")" + assert_contains "refuses to delete" "$out" "refusing to delete anything" + if [[ -f $r/boot/vmlinuz-linux ]]; then + pass "real ESP kernel preserved" + else + fail "real ESP kernel preserved" "boot-repair deleted a real ESP kernel!" + fi +} + +test_parallel_downloads_fixed() { + echo "TEST: ParallelDownloads > 1 is detected and set to 1" + local r out + r="$(make_root paralleldl)" + printf 'ParallelDownloads = 5\n' >"$r/etc/pacman.conf" + + out="$(run_repair "$r" --dry-run)" + assert_contains "detects the hook-segfault trigger" "$out" "ParallelDownloads = 5" + + out="$(run_repair "$r")" + assert_contains "sets it to 1" "$out" "set ParallelDownloads = 1" + assert_contains "config updated" "$(cat "$r/etc/pacman.conf")" "ParallelDownloads = 1" +} + +test_missing_modules_dep() { + echo "TEST: missing modules.dep is reported" + local r out + r="$(make_root nodep)" + rm -f "$r/usr/lib/modules/7.1.5-arch1-2/modules.dep" + + out="$(run_repair "$r" --dry-run)" + assert_contains "detects missing modules.dep" "$out" "modules.dep missing for 7.1.5-arch1-2" +} + +test_no_kernel_at_all() { + echo "TEST: no complete kernel tree gives actionable offline guidance" + local r out rc + r="$(make_root nokernel)" + rm -rf "$r/usr/lib/modules" + mkdir -p "$r/usr/lib/modules" + + out="$(run_repair "$r" --dry-run)" + rc=$? + assert_eq "exits non-zero" "$rc" "1" + assert_contains "explains the offline recovery" "$out" "pacman -U /var/cache/pacman/pkg/linux-" +} + +test_help_and_bad_args() { + echo "TEST: CLI contract" + local out rc + out="$("$BOOT_REPAIR" --help 2>&1)" + rc=$? + assert_eq "--help exits 0" "$rc" "0" + assert_contains "help mentions dry-run" "$out" "--dry-run" + + out="$("$BOOT_REPAIR" --nonsense 2>&1)" + rc=$? + assert_eq "unknown option exits 2" "$rc" "2" +} diff --git a/linux_configuration/scripts/boot_recovery/tests/test_boot_repair.sh b/linux_configuration/scripts/boot_recovery/tests/test_boot_repair.sh index aca769d6..442da5dc 100755 --- a/linux_configuration/scripts/boot_recovery/tests/test_boot_repair.sh +++ b/linux_configuration/scripts/boot_recovery/tests/test_boot_repair.sh @@ -62,220 +62,9 @@ assert_eq() { fi } -# ---------------------------------------------------------------------------- -# Fixture construction -# ---------------------------------------------------------------------------- +# shellcheck source=lib/boot_repair_cases.sh +source "$SCRIPT_DIR/lib/boot_repair_cases.sh" -# make_root -> prints the path to a fresh fixture root -# -# Builds a consistent system: one complete kernel tree, an ESP whose kernel -# and initramfs match it, a gating fstab and serial downloads. -make_root() { - local name="$1" - local r="$WORKROOT/$name" - local kver="${2:-7.1.5-arch1-2}" - - mkdir -p "$r/usr/lib/modules/$kver/kernel/fs/fat" - mkdir -p "$r/boot" "$r/etc" "$r/var/cache/pacman/pkg" - - # Fake kernel image carrying the version banner boot-repair greps for. - printf 'Linux version %s (linux@archlinux) fake image\n' "$kver" \ - >"$r/usr/lib/modules/$kver/vmlinuz" - : >"$r/usr/lib/modules/$kver/modules.dep" - : >"$r/usr/lib/modules/$kver/kernel/fs/fat/vfat.ko.zst" - - # ESP contents, consistent with the kernel tree. - cp "$r/usr/lib/modules/$kver/vmlinuz" "$r/boot/vmlinuz-linux" - printf 'usr/lib/modules/%s/kernel/fs/fat/vfat.ko.zst\n' "$kver" \ - >"$r/boot/initramfs-linux.img" - mkdir -p "$r/boot/EFI" "$r/boot/loader" - - printf 'UUID=DEAD-BEEF\t/boot\tvfat\tdefaults\t0 2\n' >"$r/etc/fstab" - printf 'ParallelDownloads = 1\n' >"$r/etc/pacman.conf" - - printf '%s' "$r" -} - -run_repair() { - "$BOOT_REPAIR" --root "$1" "${@:2}" 2>&1 -} - -# ---------------------------------------------------------------------------- -# Tests -# ---------------------------------------------------------------------------- - -test_healthy_system() { - echo "TEST: healthy system reports consistent" - local r out rc - r="$(make_root healthy)" - out="$(run_repair "$r" --dry-run)" - rc=$? - assert_eq "exit status is 0" "$rc" "0" - assert_contains "reports consistent" "$out" "nothing to repair" - assert_contains "picks the right kernel" "$out" "target kernel: 7.1.5-arch1-2" -} - -test_stale_esp_kernel_detected() { - echo "TEST: stale ESP kernel is detected and repaired" - local r out rc - r="$(make_root stale)" - # Simulate the real incident: ESP still holds the previous kernel. - printf 'Linux version 7.0.5-arch1-1 (linux@archlinux) fake image\n' \ - >"$r/boot/vmlinuz-linux" - - out="$(run_repair "$r" --dry-run)" - rc=$? - assert_eq "dry-run exits 1" "$rc" "1" - assert_contains "detects the mismatch" "$out" "ESP kernel is 7.0.5-arch1-1 but installed tree is 7.1.5-arch1-2" - assert_contains "dry-run changes nothing" "$out" "would install" - - # The ESP must be untouched by a dry run. - assert_contains "dry-run left ESP alone" "$(cat "$r/boot/vmlinuz-linux")" "7.0.5-arch1-1" - - out="$(run_repair "$r")" - assert_contains "repairs the kernel image" "$out" "installed kernel 7.1.5-arch1-2 to the ESP" - assert_contains "ESP now has the new kernel" "$(cat "$r/boot/vmlinuz-linux")" "7.1.5-arch1-2" -} - -test_incomplete_tree_ignored() { - echo "TEST: a gutted module tree is not chosen as the target" - local r out - r="$(make_root gutted)" - # The exact wreckage the failed upgrade leaves: metadata only, no kernel/, - # no vmlinuz. It sorts newer, so a naive 'newest' pick would take it. - mkdir -p "$r/usr/lib/modules/9.9.9-arch1-1" - : >"$r/usr/lib/modules/9.9.9-arch1-1/modules.dep" - : >"$r/usr/lib/modules/9.9.9-arch1-1/modules.alias" - - out="$(run_repair "$r" --dry-run)" - assert_contains "ignores the incomplete tree" "$out" "target kernel: 7.1.5-arch1-2" - assert_not_contains "does not pick 9.9.9" "$out" "target kernel: 9.9.9-arch1-1" -} - -test_picks_newest_complete() { - echo "TEST: newest COMPLETE tree wins" - local r out kver - r="$(make_root multi)" - kver="7.2.0-arch1-1" - mkdir -p "$r/usr/lib/modules/$kver/kernel/fs/fat" - printf 'Linux version %s (linux@archlinux) fake image\n' "$kver" \ - >"$r/usr/lib/modules/$kver/vmlinuz" - : >"$r/usr/lib/modules/$kver/modules.dep" - - out="$(run_repair "$r" --dry-run)" - assert_contains "selects the newer complete tree" "$out" "target kernel: 7.2.0-arch1-1" -} - -test_fstab_nofail_fixed() { - echo "TEST: noauto/nofail on /boot is detected and reset" - local r out - r="$(make_root fstab)" - printf 'UUID=DEAD-BEEF\t/boot\tvfat\tdefaults,noauto,nofail\t0 2\n' >"$r/etc/fstab" - - out="$(run_repair "$r" --dry-run)" - assert_contains "detects the silent-failure options" "$out" "noauto/nofail" - - out="$(run_repair "$r")" - assert_contains "resets the options" "$out" "reset to 'defaults'" - assert_contains "fstab now gates" "$(cat "$r/etc/fstab")" "defaults" - assert_not_contains "nofail is gone" "$(cat "$r/etc/fstab")" "nofail" - # A backup must exist so the change is reversible. - if compgen -G "$r/etc/fstab.bak-*" >/dev/null; then - pass "fstab backup written" - else - fail "fstab backup written" - fi -} - -test_shadow_files_removed() { - echo "TEST: shadow kernel files in an unmounted /boot are removed" - local r out - r="$(make_root shadow)" - # An unmounted /boot has no EFI/ or loader/ — that is what makes the files - # below shadows on the root filesystem rather than the real ESP. - rm -rf "${r:?}/boot" - mkdir -p "$r/boot" - printf 'Linux version 7.1.5-arch1-2 (linux@archlinux) fake\n' >"$r/boot/vmlinuz-linux" - printf 'usr/lib/modules/7.1.5-arch1-2/x\n' >"$r/boot/initramfs-linux.img" - printf 'stale\n' >"$r/boot/initramfs-linux-fallback.img" - - out="$(run_repair "$r")" - assert_contains "detects the shadows" "$out" "orphaned kernel file" - assert_contains "removes them" "$out" "removed 3 shadow file" - # The fallback image is not regenerated by a --root run, so its absence - # proves the deletion happened. (vmlinuz-linux legitimately reappears: - # sync_kernel_image reinstalls the target kernel straight afterwards.) - if [[ ! -f $r/boot/initramfs-linux-fallback.img ]]; then - pass "shadow fallback image deleted" - else - fail "shadow fallback image deleted" - fi -} - -test_shadow_guard_refuses_real_esp() { - echo "TEST: SAFETY — never delete from a directory that looks like a real ESP" - local r out - r="$(make_root guarded)" - # EFI/ and loader/ are present (make_root creates them), so even though the - # fixture is "unmounted", boot-repair must refuse to delete anything. - out="$(run_repair "$r")" - assert_contains "refuses to delete" "$out" "refusing to delete anything" - if [[ -f $r/boot/vmlinuz-linux ]]; then - pass "real ESP kernel preserved" - else - fail "real ESP kernel preserved" "boot-repair deleted a real ESP kernel!" - fi -} - -test_parallel_downloads_fixed() { - echo "TEST: ParallelDownloads > 1 is detected and set to 1" - local r out - r="$(make_root paralleldl)" - printf 'ParallelDownloads = 5\n' >"$r/etc/pacman.conf" - - out="$(run_repair "$r" --dry-run)" - assert_contains "detects the hook-segfault trigger" "$out" "ParallelDownloads = 5" - - out="$(run_repair "$r")" - assert_contains "sets it to 1" "$out" "set ParallelDownloads = 1" - assert_contains "config updated" "$(cat "$r/etc/pacman.conf")" "ParallelDownloads = 1" -} - -test_missing_modules_dep() { - echo "TEST: missing modules.dep is reported" - local r out - r="$(make_root nodep)" - rm -f "$r/usr/lib/modules/7.1.5-arch1-2/modules.dep" - - out="$(run_repair "$r" --dry-run)" - assert_contains "detects missing modules.dep" "$out" "modules.dep missing for 7.1.5-arch1-2" -} - -test_no_kernel_at_all() { - echo "TEST: no complete kernel tree gives actionable offline guidance" - local r out rc - r="$(make_root nokernel)" - rm -rf "$r/usr/lib/modules" - mkdir -p "$r/usr/lib/modules" - - out="$(run_repair "$r" --dry-run)" - rc=$? - assert_eq "exits non-zero" "$rc" "1" - assert_contains "explains the offline recovery" "$out" "pacman -U /var/cache/pacman/pkg/linux-" -} - -test_help_and_bad_args() { - echo "TEST: CLI contract" - local out rc - out="$("$BOOT_REPAIR" --help 2>&1)" - rc=$? - assert_eq "--help exits 0" "$rc" "0" - assert_contains "help mentions dry-run" "$out" "--dry-run" - - out="$("$BOOT_REPAIR" --nonsense 2>&1)" - rc=$? - assert_eq "unknown option exits 2" "$rc" "2" -} # ---------------------------------------------------------------------------- # Runner diff --git a/linux_configuration/scripts/single_use/features/lib/pi_flash_exec.sh b/linux_configuration/scripts/single_use/features/lib/pi_flash_exec.sh new file mode 100755 index 00000000..2d5907ed --- /dev/null +++ b/linux_configuration/scripts/single_use/features/lib/pi_flash_exec.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Flashing from the remote laptop. +# +# Sourced by raspberry_pi_flash_sd.sh; split out to keep it under the 250-line +# cap. Sourced rather than run, so it inherits the caller's strict mode +# and the variables defined above the source line. + +# ============================================================================= +# Remote Flash +# ============================================================================= + +phase_flash_remote() { + log_info "=== Flash Raspberry Pi OS to SD Card on Remote Laptop ===" + + discover_remote_laptop + + setup_ssh_key_to_remote "$REMOTE_LAPTOP_IP" "$REMOTE_LAPTOP_USER" + + local remote="${REMOTE_LAPTOP_USER}@${REMOTE_LAPTOP_IP}" + + log_info "Checking for SD card on remote laptop..." + echo "Block devices on ${remote}:" + ssh "$remote" "lsblk -d -o NAME,SIZE,TYPE,RM,TRAN,MODEL" || true + echo + + log_info "Auto-detecting SD card on remote laptop..." + local sd_device + sd_device=$(ssh "$remote" "lsblk -d -o NAME,RM,TRAN | grep -E '1.*(usb|mmc)' | awk '{print \"/dev/\"\$1}' | head -1" 2> /dev/null || true) + + if [[ -z $sd_device ]]; then + die "No SD card detected on remote laptop. Please insert an SD card and try again." + fi + + local sd_info + # shellcheck disable=SC2029 # Intentional client-side expansion + sd_info=$(ssh "$remote" "lsblk -d -o NAME,SIZE,MODEL $sd_device 2>/dev/null | tail -1" || true) + + log_success "Auto-detected SD card: $sd_device ($sd_info)" + SD_CARD_DEVICE="$sd_device" + + # shellcheck disable=SC2029 # Intentional client-side expansion + if ! ssh "$remote" "[[ -b '$SD_CARD_DEVICE' ]]" 2> /dev/null; then + die "Device $SD_CARD_DEVICE does not exist on remote laptop" + fi + + auto_generate_pi_password + log_success "Pi user '$PI_USER' password: $PI_PASSWORD" + + local encrypted_password + encrypted_password=$(echo "$PI_PASSWORD" | openssl passwd -6 -stdin) + + save_config + + log_info "Copying script to remote laptop..." + scp "$0" "${remote}:/tmp/raspberry_pi_flash_sd.sh" + + log_info "Executing flash on remote laptop..." + log_warning "This will ERASE ALL DATA on ${SD_CARD_DEVICE} on the remote laptop!" + log_info "Proceeding automatically in 5 seconds... (Ctrl+C to cancel)" + sleep 5 + + ssh -tt "$remote" "sudo SD_CARD_DEVICE='$SD_CARD_DEVICE' PI_USER='$PI_USER' PI_HOSTNAME='$PI_HOSTNAME' bash /tmp/raspberry_pi_flash_sd.sh execute-remote '$encrypted_password'" + + log_success "Flash complete!" + echo + log_info "Pi credentials:" + log_info " User: $PI_USER" + log_info " Password: $PI_PASSWORD" + log_info " Hostname: $PI_HOSTNAME" + echo + log_info "Next steps:" + log_info "1. Remove SD card from the laptop and insert into Raspberry Pi" + log_info "2. Connect the Pi to power and network" + log_info "3. Wait 2-3 minutes for first boot" +} + +# Called on the remote laptop by phase_flash_remote +phase_execute_remote() { + check_root + + local encrypted_password="${1:-}" + + log_info "=== Executing Flash on Remote Laptop ===" + + if [[ -z $SD_CARD_DEVICE ]]; then + die "SD_CARD_DEVICE not set" + fi + + local image_path + image_path=$(download_raspberry_pi_os) + + log_info "Unmounting partitions on $SD_CARD_DEVICE..." + for partition in "${SD_CARD_DEVICE}"*; do + if mountpoint -q "$partition" 2> /dev/null || mount | grep -q "$partition"; then + umount "$partition" 2> /dev/null || true + fi + done + + log_info "Flashing image to SD card..." + dd if="$image_path" of="$SD_CARD_DEVICE" bs=4M status=progress conv=fsync + sync + log_success "Image flashed successfully!" + + log_info "Configuring headless boot..." + sleep 2 + partprobe "$SD_CARD_DEVICE" 2> /dev/null || true + sleep 2 + + local boot_partition + if [[ -b "${SD_CARD_DEVICE}1" ]]; then + boot_partition="${SD_CARD_DEVICE}1" + elif [[ -b "${SD_CARD_DEVICE}p1" ]]; then + boot_partition="${SD_CARD_DEVICE}p1" + else + die "Could not find boot partition" + fi + + local boot_mount="/tmp/rpi-boot" + mkdir -p "$boot_mount" + mount "$boot_partition" "$boot_mount" + + touch "$boot_mount/ssh" + log_success "SSH enabled" + + if [[ -n $encrypted_password ]]; then + echo "${PI_USER}:${encrypted_password}" > "$boot_mount/userconf.txt" + log_success "User '$PI_USER' configured" + fi + + local root_partition + if [[ -b "${SD_CARD_DEVICE}2" ]]; then + root_partition="${SD_CARD_DEVICE}2" + elif [[ -b "${SD_CARD_DEVICE}p2" ]]; then + root_partition="${SD_CARD_DEVICE}p2" + fi + + if [[ -n $root_partition ]]; then + local root_mount="/tmp/rpi-root" + mkdir -p "$root_mount" + mount "$root_partition" "$root_mount" + + echo "$PI_HOSTNAME" > "$root_mount/etc/hostname" + sed -i "s/raspberrypi/$PI_HOSTNAME/g" "$root_mount/etc/hosts" + + log_success "Hostname set to '$PI_HOSTNAME'" + + umount "$root_mount" + fi + + umount "$boot_mount" + sync + + log_success "SD card configured for headless boot!" +} + +# ============================================================================= +# Main +# ============================================================================= + +show_help() { + cat << 'EOF' +Raspberry Pi SD Card Flash Script + +Usage: ./raspberry_pi_flash_sd.sh + +Commands: + local Flash SD card locally (requires root) + remote Flash SD card on a remote laptop via SSH + execute-remote Internal: executed on remote laptop + help Show this help message + +The script will: +1. Auto-discover a remote laptop with an SD card (for remote mode) +2. Download Raspberry Pi OS Lite (64-bit) +3. Flash the image to the SD card +4. Configure headless boot (SSH enabled, user created, hostname set) + +Credentials are auto-generated and saved to .raspberry_pi.conf + +Examples: + # Flash locally (run as root) + sudo ./raspberry_pi_flash_sd.sh local + + # Flash on remote laptop + ./raspberry_pi_flash_sd.sh remote +EOF +} diff --git a/linux_configuration/scripts/single_use/features/lib/pi_flash_image.sh b/linux_configuration/scripts/single_use/features/lib/pi_flash_image.sh new file mode 100755 index 00000000..6a9d3ccb --- /dev/null +++ b/linux_configuration/scripts/single_use/features/lib/pi_flash_image.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# OS image download and the local flash phase. +# +# Sourced by raspberry_pi_flash_sd.sh; split out to keep it under the 250-line +# cap. Sourced rather than run, so it inherits the caller's strict mode +# and the variables defined above the source line. + +# ============================================================================= +# Download and Flash Functions +# ============================================================================= + +download_raspberry_pi_os() { + local download_dir="/tmp/rpi-image" + local image_url="https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-11-19/2024-11-19-raspios-bookworm-arm64-lite.img.xz" + local image_file="$download_dir/raspios.img.xz" + local extracted_image="$download_dir/raspios.img" + local expected_size=459000608 + + mkdir -p "$download_dir" + + if [[ -f $extracted_image ]]; then + log_info "Using existing image at $extracted_image" + echo "$extracted_image" + return + fi + + if [[ -f $image_file ]]; then + local actual_size + actual_size=$(stat -c%s "$image_file" 2> /dev/null || stat -f%z "$image_file" 2> /dev/null || echo 0) + if [[ $actual_size -lt $expected_size ]]; then + log_warning "Incomplete download detected ($actual_size < $expected_size bytes), re-downloading..." + rm -f "$image_file" + else + log_info "Image archive already downloaded" + fi + fi + + if [[ ! -f $image_file ]]; then + log_info "Downloading Raspberry Pi OS Lite (64-bit)..." + log_info "This may take a while depending on your internet connection..." + + if command -v aria2c &> /dev/null; then + aria2c -x 4 -c -d "$download_dir" --out="raspios.img.xz" "$image_url" >&2 + elif command -v wget &> /dev/null; then + wget --continue --show-progress -O "$image_file" "$image_url" >&2 + elif command -v curl &> /dev/null; then + curl -L -C - -o "$image_file" "$image_url" --progress-bar >&2 + else + die "No download tool available. Install wget, curl, or aria2c" + fi + + local actual_size + actual_size=$(stat -c%s "$image_file" 2> /dev/null || stat -f%z "$image_file" 2> /dev/null || echo 0) + if [[ $actual_size -lt $expected_size ]]; then + die "Download incomplete: got $actual_size bytes, expected $expected_size" + fi + log_success "Download complete: $actual_size bytes" + fi + + log_info "Extracting image..." + xz -dk "$image_file" + + if [[ ! -f $extracted_image ]]; then + die "Failed to extract image" + fi + + echo "$extracted_image" +} + +# ============================================================================= +# Local Flash +# ============================================================================= + +phase_flash_local() { + check_root + + log_info "=== Flash Raspberry Pi OS to SD Card (Local) ===" + + # Detect SD card + log_info "Detecting removable storage devices..." + local devices + devices=$(lsblk -d -o NAME,SIZE,TYPE,RM,TRAN | grep -E "disk.*1.*usb|disk.*1.*mmc" | awk '{print "/dev/"$1" ("$2")"}') + + if [[ -z $devices ]]; then + log_warning "No removable devices detected automatically." + lsblk -d -o NAME,SIZE,TYPE,RM,TRAN + read -r -p "Enter the SD card device path (e.g., /dev/sdb): " SD_CARD_DEVICE + else + echo "Detected removable devices:" + echo "$devices" + read -r -p "Enter the SD card device path from above (e.g., /dev/sdb): " SD_CARD_DEVICE + fi + + if [[ ! -b $SD_CARD_DEVICE ]]; then + die "Device $SD_CARD_DEVICE does not exist or is not a block device" + fi + + local root_device + root_device=$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | sed 's/p[0-9]*$//') + if [[ $SD_CARD_DEVICE == "$root_device" ]]; then + die "Cannot flash to the system drive!" + fi + + auto_generate_pi_password + + local encrypted_password + encrypted_password=$(echo "$PI_PASSWORD" | openssl passwd -6 -stdin) + + save_config + + local image_path + image_path=$(download_raspberry_pi_os) + + log_warning "This will ERASE ALL DATA on $SD_CARD_DEVICE" + read -r -p "Are you sure you want to continue? (yes/no): " confirm + + if [[ $confirm != "yes" ]]; then + die "Aborted by user" + fi + + log_info "Unmounting partitions on $SD_CARD_DEVICE..." + for partition in "${SD_CARD_DEVICE}"*; do + if mountpoint -q "$partition" 2> /dev/null || mount | grep -q "$partition"; then + umount "$partition" 2> /dev/null || true + fi + done + + log_info "Flashing image to SD card..." + dd if="$image_path" of="$SD_CARD_DEVICE" bs=4M status=progress conv=fsync + sync + log_success "Image flashed successfully!" + + # Configure headless boot + log_info "Configuring headless boot..." + sleep 2 + partprobe "$SD_CARD_DEVICE" 2> /dev/null || true + sleep 2 + + local boot_partition + if [[ -b "${SD_CARD_DEVICE}1" ]]; then + boot_partition="${SD_CARD_DEVICE}1" + elif [[ -b "${SD_CARD_DEVICE}p1" ]]; then + boot_partition="${SD_CARD_DEVICE}p1" + else + die "Could not find boot partition" + fi + + local boot_mount="/tmp/rpi-boot" + mkdir -p "$boot_mount" + mount "$boot_partition" "$boot_mount" + + touch "$boot_mount/ssh" + log_success "SSH enabled" + + echo "${PI_USER}:${encrypted_password}" > "$boot_mount/userconf.txt" + log_success "User '$PI_USER' configured" + + local root_partition + if [[ -b "${SD_CARD_DEVICE}2" ]]; then + root_partition="${SD_CARD_DEVICE}2" + elif [[ -b "${SD_CARD_DEVICE}p2" ]]; then + root_partition="${SD_CARD_DEVICE}p2" + fi + + if [[ -n $root_partition ]]; then + local root_mount="/tmp/rpi-root" + mkdir -p "$root_mount" + mount "$root_partition" "$root_mount" + + echo "$PI_HOSTNAME" > "$root_mount/etc/hostname" + sed -i "s/raspberrypi/$PI_HOSTNAME/g" "$root_mount/etc/hosts" + + log_success "Hostname set to '$PI_HOSTNAME'" + + umount "$root_mount" + fi + + umount "$boot_mount" + sync + + log_success "SD card configured for headless boot!" + log_success "Flash complete!" + echo + log_info "Pi credentials:" + log_info " User: $PI_USER" + log_info " Password: $PI_PASSWORD" + log_info " Hostname: $PI_HOSTNAME" + echo + log_info "Next steps:" + log_info "1. Remove SD card and insert into Raspberry Pi" + log_info "2. Connect the Pi to power and network" + log_info "3. Wait 2-3 minutes for first boot" +} diff --git a/linux_configuration/scripts/single_use/features/lib/pi_flash_remote.sh b/linux_configuration/scripts/single_use/features/lib/pi_flash_remote.sh new file mode 100755 index 00000000..d7df234c --- /dev/null +++ b/linux_configuration/scripts/single_use/features/lib/pi_flash_remote.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Remote laptop discovery and SSH key setup. +# +# Sourced by raspberry_pi_flash_sd.sh; split out to keep it under the 250-line +# cap. Sourced rather than run, so it inherits the caller's strict mode +# and the variables defined above the source line. + +discover_remote_laptop() { + log_info "Auto-discovering remote laptop on local network..." + + ensure_dependencies + + local my_ip + my_ip=$(ip -4 addr show | grep -oP '(?<=inet\s)(?!127\.)\d+(\.\d+){3}' | head -1) + + local gateway + gateway=$(ip route | grep default | awk '{print $3}' | head -1) + local network="${gateway%.*}.0/24" + + log_info "Local IP: $my_ip, Gateway: $gateway, Network: $network" + log_info "Scanning network for SSH-enabled devices (using nmap)..." + + local ssh_hosts + nmap -sn -T4 "$network" &> /dev/null || true + ssh_hosts=$(nmap -p 22 --open -sT -T4 "$network" 2> /dev/null | grep "Nmap scan report" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | grep -vw "$my_ip" | sort -u) + + if [[ -z $ssh_hosts ]]; then + die "No SSH-enabled devices found on network" + fi + + local host_count + host_count=$(echo "$ssh_hosts" | wc -l) + log_info "Found $host_count SSH-enabled device(s): $(echo "$ssh_hosts" | tr '\n' ' ')" + + local common_users=("$REMOTE_LAPTOP_USER" "kuchy" "kuhy" "$(whoami)" "pi" "user" "admin") + local users=() + for u in "${common_users[@]}"; do + local is_dup=0 + for existing in "${users[@]}"; do + if [[ $u == "$existing" ]]; then + is_dup=1 + break + fi + done + if [[ $is_dup -eq 0 ]]; then + users+=("$u") + fi + done + + log_info "Will try usernames: ${users[*]}" + + local found_laptop="" + local found_user="" + local idx=0 + + for ip in $ssh_hosts; do + idx=$((idx + 1)) + + if [[ $ip == "$gateway" ]]; then + log_info "[$idx/$host_count] Skipping $ip (gateway)" + continue + fi + + log_info "[$idx/$host_count] $ip - Trying SSH key access with common usernames..." + + for try_user in "${users[@]}"; do + if ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=accept-new "${try_user}@${ip}" "echo ok" 2> /dev/null | grep -q "ok"; then + log_success "[$idx/$host_count] $ip - SSH key access confirmed with user '$try_user'!" + found_user="$try_user" + + log_info "[$idx/$host_count] $ip - Checking for SD card..." + local has_sd + has_sd=$(ssh -o BatchMode=yes -o ConnectTimeout=2 "${try_user}@${ip}" "lsblk -d -o NAME,RM,TRAN 2>/dev/null | grep -E '1.*(usb|mmc)' | head -1" 2> /dev/null || true) + + if [[ -n $has_sd ]]; then + log_success "[$idx/$host_count] $ip - Found SD card: $has_sd" + found_laptop="$ip" + break 2 + else + log_warning "[$idx/$host_count] $ip - No SD card detected, saving as fallback..." + if [[ -z $found_laptop ]]; then + found_laptop="$ip" + fi + fi + break + fi + done + done + + if [[ -z $found_laptop ]] || [[ -z $found_user ]]; then + log_warning "No device with passwordless SSH found using common usernames." + + found_laptop=$(echo "$ssh_hosts" | grep -vw "$gateway" | head -1) + + if [[ -z $found_laptop ]]; then + die "Could not find any suitable SSH-enabled device" + fi + + log_info "Found SSH host at $found_laptop but need credentials." + read -r -p "Enter username for $found_laptop: " found_user + + if [[ -z $found_user ]]; then + die "No username provided" + fi + fi + + REMOTE_LAPTOP_IP="$found_laptop" + REMOTE_LAPTOP_USER="$found_user" + log_success "Selected remote laptop: ${REMOTE_LAPTOP_USER}@${REMOTE_LAPTOP_IP}" + + save_config +} + +setup_ssh_key_to_remote() { + local remote_host="$1" + local remote_user="$2" + + if ssh -o BatchMode=yes -o ConnectTimeout=5 "${remote_user}@${remote_host}" "echo 'SSH key works'" 2> /dev/null; then + log_success "SSH key authentication to ${remote_user}@${remote_host} already configured" + return 0 + fi + + log_info "Setting up SSH key authentication to ${remote_user}@${remote_host}..." + + if [[ ! -f ~/.ssh/id_rsa.pub ]] && [[ ! -f ~/.ssh/id_ed25519.pub ]]; then + log_info "Generating SSH key..." + ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -q + fi + + log_info "Copying SSH key to remote host (you may be prompted for password)..." + + if command -v ssh-copy-id &> /dev/null; then + ssh-copy-id -o StrictHostKeyChecking=no "${remote_user}@${remote_host}" + else + local pub_key + pub_key=$(cat ~/.ssh/id_ed25519.pub 2> /dev/null || cat ~/.ssh/id_rsa.pub) + ssh -o StrictHostKeyChecking=no "${remote_user}@${remote_host}" "mkdir -p ~/.ssh && echo '$pub_key' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" + fi + + log_success "SSH key authentication configured" +} diff --git a/linux_configuration/scripts/single_use/features/raspberry_pi_flash_sd.sh b/linux_configuration/scripts/single_use/features/raspberry_pi_flash_sd.sh index 3b77c398..5b46c492 100755 --- a/linux_configuration/scripts/single_use/features/raspberry_pi_flash_sd.sh +++ b/linux_configuration/scripts/single_use/features/raspberry_pi_flash_sd.sh @@ -131,508 +131,13 @@ ensure_dependencies() { fi } -discover_remote_laptop() { - log_info "Auto-discovering remote laptop on local network..." +# shellcheck source=lib/pi_flash_remote.sh +source "$SCRIPT_DIR/lib/pi_flash_remote.sh" +# shellcheck source=lib/pi_flash_image.sh +source "$SCRIPT_DIR/lib/pi_flash_image.sh" +# shellcheck source=lib/pi_flash_exec.sh +source "$SCRIPT_DIR/lib/pi_flash_exec.sh" - ensure_dependencies - - local my_ip - my_ip=$(ip -4 addr show | grep -oP '(?<=inet\s)(?!127\.)\d+(\.\d+){3}' | head -1) - - local gateway - gateway=$(ip route | grep default | awk '{print $3}' | head -1) - local network="${gateway%.*}.0/24" - - log_info "Local IP: $my_ip, Gateway: $gateway, Network: $network" - log_info "Scanning network for SSH-enabled devices (using nmap)..." - - local ssh_hosts - nmap -sn -T4 "$network" &> /dev/null || true - ssh_hosts=$(nmap -p 22 --open -sT -T4 "$network" 2> /dev/null | grep "Nmap scan report" | grep -oP '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | grep -vw "$my_ip" | sort -u) - - if [[ -z $ssh_hosts ]]; then - die "No SSH-enabled devices found on network" - fi - - local host_count - host_count=$(echo "$ssh_hosts" | wc -l) - log_info "Found $host_count SSH-enabled device(s): $(echo "$ssh_hosts" | tr '\n' ' ')" - - local common_users=("$REMOTE_LAPTOP_USER" "kuchy" "kuhy" "$(whoami)" "pi" "user" "admin") - local users=() - for u in "${common_users[@]}"; do - local is_dup=0 - for existing in "${users[@]}"; do - if [[ $u == "$existing" ]]; then - is_dup=1 - break - fi - done - if [[ $is_dup -eq 0 ]]; then - users+=("$u") - fi - done - - log_info "Will try usernames: ${users[*]}" - - local found_laptop="" - local found_user="" - local idx=0 - - for ip in $ssh_hosts; do - idx=$((idx + 1)) - - if [[ $ip == "$gateway" ]]; then - log_info "[$idx/$host_count] Skipping $ip (gateway)" - continue - fi - - log_info "[$idx/$host_count] $ip - Trying SSH key access with common usernames..." - - for try_user in "${users[@]}"; do - if ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=accept-new "${try_user}@${ip}" "echo ok" 2> /dev/null | grep -q "ok"; then - log_success "[$idx/$host_count] $ip - SSH key access confirmed with user '$try_user'!" - found_user="$try_user" - - log_info "[$idx/$host_count] $ip - Checking for SD card..." - local has_sd - has_sd=$(ssh -o BatchMode=yes -o ConnectTimeout=2 "${try_user}@${ip}" "lsblk -d -o NAME,RM,TRAN 2>/dev/null | grep -E '1.*(usb|mmc)' | head -1" 2> /dev/null || true) - - if [[ -n $has_sd ]]; then - log_success "[$idx/$host_count] $ip - Found SD card: $has_sd" - found_laptop="$ip" - break 2 - else - log_warning "[$idx/$host_count] $ip - No SD card detected, saving as fallback..." - if [[ -z $found_laptop ]]; then - found_laptop="$ip" - fi - fi - break - fi - done - done - - if [[ -z $found_laptop ]] || [[ -z $found_user ]]; then - log_warning "No device with passwordless SSH found using common usernames." - - found_laptop=$(echo "$ssh_hosts" | grep -vw "$gateway" | head -1) - - if [[ -z $found_laptop ]]; then - die "Could not find any suitable SSH-enabled device" - fi - - log_info "Found SSH host at $found_laptop but need credentials." - read -r -p "Enter username for $found_laptop: " found_user - - if [[ -z $found_user ]]; then - die "No username provided" - fi - fi - - REMOTE_LAPTOP_IP="$found_laptop" - REMOTE_LAPTOP_USER="$found_user" - log_success "Selected remote laptop: ${REMOTE_LAPTOP_USER}@${REMOTE_LAPTOP_IP}" - - save_config -} - -setup_ssh_key_to_remote() { - local remote_host="$1" - local remote_user="$2" - - if ssh -o BatchMode=yes -o ConnectTimeout=5 "${remote_user}@${remote_host}" "echo 'SSH key works'" 2> /dev/null; then - log_success "SSH key authentication to ${remote_user}@${remote_host} already configured" - return 0 - fi - - log_info "Setting up SSH key authentication to ${remote_user}@${remote_host}..." - - if [[ ! -f ~/.ssh/id_rsa.pub ]] && [[ ! -f ~/.ssh/id_ed25519.pub ]]; then - log_info "Generating SSH key..." - ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -q - fi - - log_info "Copying SSH key to remote host (you may be prompted for password)..." - - if command -v ssh-copy-id &> /dev/null; then - ssh-copy-id -o StrictHostKeyChecking=no "${remote_user}@${remote_host}" - else - local pub_key - pub_key=$(cat ~/.ssh/id_ed25519.pub 2> /dev/null || cat ~/.ssh/id_rsa.pub) - ssh -o StrictHostKeyChecking=no "${remote_user}@${remote_host}" "mkdir -p ~/.ssh && echo '$pub_key' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" - fi - - log_success "SSH key authentication configured" -} - -# ============================================================================= -# Download and Flash Functions -# ============================================================================= - -download_raspberry_pi_os() { - local download_dir="/tmp/rpi-image" - local image_url="https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-11-19/2024-11-19-raspios-bookworm-arm64-lite.img.xz" - local image_file="$download_dir/raspios.img.xz" - local extracted_image="$download_dir/raspios.img" - local expected_size=459000608 - - mkdir -p "$download_dir" - - if [[ -f $extracted_image ]]; then - log_info "Using existing image at $extracted_image" - echo "$extracted_image" - return - fi - - if [[ -f $image_file ]]; then - local actual_size - actual_size=$(stat -c%s "$image_file" 2> /dev/null || stat -f%z "$image_file" 2> /dev/null || echo 0) - if [[ $actual_size -lt $expected_size ]]; then - log_warning "Incomplete download detected ($actual_size < $expected_size bytes), re-downloading..." - rm -f "$image_file" - else - log_info "Image archive already downloaded" - fi - fi - - if [[ ! -f $image_file ]]; then - log_info "Downloading Raspberry Pi OS Lite (64-bit)..." - log_info "This may take a while depending on your internet connection..." - - if command -v aria2c &> /dev/null; then - aria2c -x 4 -c -d "$download_dir" --out="raspios.img.xz" "$image_url" >&2 - elif command -v wget &> /dev/null; then - wget --continue --show-progress -O "$image_file" "$image_url" >&2 - elif command -v curl &> /dev/null; then - curl -L -C - -o "$image_file" "$image_url" --progress-bar >&2 - else - die "No download tool available. Install wget, curl, or aria2c" - fi - - local actual_size - actual_size=$(stat -c%s "$image_file" 2> /dev/null || stat -f%z "$image_file" 2> /dev/null || echo 0) - if [[ $actual_size -lt $expected_size ]]; then - die "Download incomplete: got $actual_size bytes, expected $expected_size" - fi - log_success "Download complete: $actual_size bytes" - fi - - log_info "Extracting image..." - xz -dk "$image_file" - - if [[ ! -f $extracted_image ]]; then - die "Failed to extract image" - fi - - echo "$extracted_image" -} - -# ============================================================================= -# Local Flash -# ============================================================================= - -phase_flash_local() { - check_root - - log_info "=== Flash Raspberry Pi OS to SD Card (Local) ===" - - # Detect SD card - log_info "Detecting removable storage devices..." - local devices - devices=$(lsblk -d -o NAME,SIZE,TYPE,RM,TRAN | grep -E "disk.*1.*usb|disk.*1.*mmc" | awk '{print "/dev/"$1" ("$2")"}') - - if [[ -z $devices ]]; then - log_warning "No removable devices detected automatically." - lsblk -d -o NAME,SIZE,TYPE,RM,TRAN - read -r -p "Enter the SD card device path (e.g., /dev/sdb): " SD_CARD_DEVICE - else - echo "Detected removable devices:" - echo "$devices" - read -r -p "Enter the SD card device path from above (e.g., /dev/sdb): " SD_CARD_DEVICE - fi - - if [[ ! -b $SD_CARD_DEVICE ]]; then - die "Device $SD_CARD_DEVICE does not exist or is not a block device" - fi - - local root_device - root_device=$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | sed 's/p[0-9]*$//') - if [[ $SD_CARD_DEVICE == "$root_device" ]]; then - die "Cannot flash to the system drive!" - fi - - auto_generate_pi_password - - local encrypted_password - encrypted_password=$(echo "$PI_PASSWORD" | openssl passwd -6 -stdin) - - save_config - - local image_path - image_path=$(download_raspberry_pi_os) - - log_warning "This will ERASE ALL DATA on $SD_CARD_DEVICE" - read -r -p "Are you sure you want to continue? (yes/no): " confirm - - if [[ $confirm != "yes" ]]; then - die "Aborted by user" - fi - - log_info "Unmounting partitions on $SD_CARD_DEVICE..." - for partition in "${SD_CARD_DEVICE}"*; do - if mountpoint -q "$partition" 2> /dev/null || mount | grep -q "$partition"; then - umount "$partition" 2> /dev/null || true - fi - done - - log_info "Flashing image to SD card..." - dd if="$image_path" of="$SD_CARD_DEVICE" bs=4M status=progress conv=fsync - sync - log_success "Image flashed successfully!" - - # Configure headless boot - log_info "Configuring headless boot..." - sleep 2 - partprobe "$SD_CARD_DEVICE" 2> /dev/null || true - sleep 2 - - local boot_partition - if [[ -b "${SD_CARD_DEVICE}1" ]]; then - boot_partition="${SD_CARD_DEVICE}1" - elif [[ -b "${SD_CARD_DEVICE}p1" ]]; then - boot_partition="${SD_CARD_DEVICE}p1" - else - die "Could not find boot partition" - fi - - local boot_mount="/tmp/rpi-boot" - mkdir -p "$boot_mount" - mount "$boot_partition" "$boot_mount" - - touch "$boot_mount/ssh" - log_success "SSH enabled" - - echo "${PI_USER}:${encrypted_password}" > "$boot_mount/userconf.txt" - log_success "User '$PI_USER' configured" - - local root_partition - if [[ -b "${SD_CARD_DEVICE}2" ]]; then - root_partition="${SD_CARD_DEVICE}2" - elif [[ -b "${SD_CARD_DEVICE}p2" ]]; then - root_partition="${SD_CARD_DEVICE}p2" - fi - - if [[ -n $root_partition ]]; then - local root_mount="/tmp/rpi-root" - mkdir -p "$root_mount" - mount "$root_partition" "$root_mount" - - echo "$PI_HOSTNAME" > "$root_mount/etc/hostname" - sed -i "s/raspberrypi/$PI_HOSTNAME/g" "$root_mount/etc/hosts" - - log_success "Hostname set to '$PI_HOSTNAME'" - - umount "$root_mount" - fi - - umount "$boot_mount" - sync - - log_success "SD card configured for headless boot!" - log_success "Flash complete!" - echo - log_info "Pi credentials:" - log_info " User: $PI_USER" - log_info " Password: $PI_PASSWORD" - log_info " Hostname: $PI_HOSTNAME" - echo - log_info "Next steps:" - log_info "1. Remove SD card and insert into Raspberry Pi" - log_info "2. Connect the Pi to power and network" - log_info "3. Wait 2-3 minutes for first boot" -} - -# ============================================================================= -# Remote Flash -# ============================================================================= - -phase_flash_remote() { - log_info "=== Flash Raspberry Pi OS to SD Card on Remote Laptop ===" - - discover_remote_laptop - - setup_ssh_key_to_remote "$REMOTE_LAPTOP_IP" "$REMOTE_LAPTOP_USER" - - local remote="${REMOTE_LAPTOP_USER}@${REMOTE_LAPTOP_IP}" - - log_info "Checking for SD card on remote laptop..." - echo "Block devices on ${remote}:" - ssh "$remote" "lsblk -d -o NAME,SIZE,TYPE,RM,TRAN,MODEL" || true - echo - - log_info "Auto-detecting SD card on remote laptop..." - local sd_device - sd_device=$(ssh "$remote" "lsblk -d -o NAME,RM,TRAN | grep -E '1.*(usb|mmc)' | awk '{print \"/dev/\"\$1}' | head -1" 2> /dev/null || true) - - if [[ -z $sd_device ]]; then - die "No SD card detected on remote laptop. Please insert an SD card and try again." - fi - - local sd_info - # shellcheck disable=SC2029 # Intentional client-side expansion - sd_info=$(ssh "$remote" "lsblk -d -o NAME,SIZE,MODEL $sd_device 2>/dev/null | tail -1" || true) - - log_success "Auto-detected SD card: $sd_device ($sd_info)" - SD_CARD_DEVICE="$sd_device" - - # shellcheck disable=SC2029 # Intentional client-side expansion - if ! ssh "$remote" "[[ -b '$SD_CARD_DEVICE' ]]" 2> /dev/null; then - die "Device $SD_CARD_DEVICE does not exist on remote laptop" - fi - - auto_generate_pi_password - log_success "Pi user '$PI_USER' password: $PI_PASSWORD" - - local encrypted_password - encrypted_password=$(echo "$PI_PASSWORD" | openssl passwd -6 -stdin) - - save_config - - log_info "Copying script to remote laptop..." - scp "$0" "${remote}:/tmp/raspberry_pi_flash_sd.sh" - - log_info "Executing flash on remote laptop..." - log_warning "This will ERASE ALL DATA on ${SD_CARD_DEVICE} on the remote laptop!" - log_info "Proceeding automatically in 5 seconds... (Ctrl+C to cancel)" - sleep 5 - - ssh -tt "$remote" "sudo SD_CARD_DEVICE='$SD_CARD_DEVICE' PI_USER='$PI_USER' PI_HOSTNAME='$PI_HOSTNAME' bash /tmp/raspberry_pi_flash_sd.sh execute-remote '$encrypted_password'" - - log_success "Flash complete!" - echo - log_info "Pi credentials:" - log_info " User: $PI_USER" - log_info " Password: $PI_PASSWORD" - log_info " Hostname: $PI_HOSTNAME" - echo - log_info "Next steps:" - log_info "1. Remove SD card from the laptop and insert into Raspberry Pi" - log_info "2. Connect the Pi to power and network" - log_info "3. Wait 2-3 minutes for first boot" -} - -# Called on the remote laptop by phase_flash_remote -phase_execute_remote() { - check_root - - local encrypted_password="${1:-}" - - log_info "=== Executing Flash on Remote Laptop ===" - - if [[ -z $SD_CARD_DEVICE ]]; then - die "SD_CARD_DEVICE not set" - fi - - local image_path - image_path=$(download_raspberry_pi_os) - - log_info "Unmounting partitions on $SD_CARD_DEVICE..." - for partition in "${SD_CARD_DEVICE}"*; do - if mountpoint -q "$partition" 2> /dev/null || mount | grep -q "$partition"; then - umount "$partition" 2> /dev/null || true - fi - done - - log_info "Flashing image to SD card..." - dd if="$image_path" of="$SD_CARD_DEVICE" bs=4M status=progress conv=fsync - sync - log_success "Image flashed successfully!" - - log_info "Configuring headless boot..." - sleep 2 - partprobe "$SD_CARD_DEVICE" 2> /dev/null || true - sleep 2 - - local boot_partition - if [[ -b "${SD_CARD_DEVICE}1" ]]; then - boot_partition="${SD_CARD_DEVICE}1" - elif [[ -b "${SD_CARD_DEVICE}p1" ]]; then - boot_partition="${SD_CARD_DEVICE}p1" - else - die "Could not find boot partition" - fi - - local boot_mount="/tmp/rpi-boot" - mkdir -p "$boot_mount" - mount "$boot_partition" "$boot_mount" - - touch "$boot_mount/ssh" - log_success "SSH enabled" - - if [[ -n $encrypted_password ]]; then - echo "${PI_USER}:${encrypted_password}" > "$boot_mount/userconf.txt" - log_success "User '$PI_USER' configured" - fi - - local root_partition - if [[ -b "${SD_CARD_DEVICE}2" ]]; then - root_partition="${SD_CARD_DEVICE}2" - elif [[ -b "${SD_CARD_DEVICE}p2" ]]; then - root_partition="${SD_CARD_DEVICE}p2" - fi - - if [[ -n $root_partition ]]; then - local root_mount="/tmp/rpi-root" - mkdir -p "$root_mount" - mount "$root_partition" "$root_mount" - - echo "$PI_HOSTNAME" > "$root_mount/etc/hostname" - sed -i "s/raspberrypi/$PI_HOSTNAME/g" "$root_mount/etc/hosts" - - log_success "Hostname set to '$PI_HOSTNAME'" - - umount "$root_mount" - fi - - umount "$boot_mount" - sync - - log_success "SD card configured for headless boot!" -} - -# ============================================================================= -# Main -# ============================================================================= - -show_help() { - cat << 'EOF' -Raspberry Pi SD Card Flash Script - -Usage: ./raspberry_pi_flash_sd.sh - -Commands: - local Flash SD card locally (requires root) - remote Flash SD card on a remote laptop via SSH - execute-remote Internal: executed on remote laptop - help Show this help message - -The script will: -1. Auto-discover a remote laptop with an SD card (for remote mode) -2. Download Raspberry Pi OS Lite (64-bit) -3. Flash the image to the SD card -4. Configure headless boot (SSH enabled, user created, hostname set) - -Credentials are auto-generated and saved to .raspberry_pi.conf - -Examples: - # Flash locally (run as root) - sudo ./raspberry_pi_flash_sd.sh local - - # Flash on remote laptop - ./raspberry_pi_flash_sd.sh remote -EOF -} main() { local command="${1:-help}"