diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfc14d8c5..abcef5080 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,8 @@ # The boot-test job then boots every IRIX image on the Linux, macOS and Windows # CLI builds: a platform x irix matrix, each image listed once and pinned by URL # and sha256. IRIX 5.3 boots once on the serial console and once on the -# graphics console; IRIX 6.5.22 on the serial console only. +# graphics console; IRIX 6.5.22 and 6.5.30 on the serial console only. The +# 6.5.30 image is an O2, which has no PROM built in, so its entry pins one too. # # Runner images are pinned: a glibc-linked Linux binary needs at least the # build host's glibc, so ubuntu-24.04 decides which distros our packages run on, @@ -405,6 +406,10 @@ jobs: # Stock IRIX 6.5.22 Indy install, 865 MB. Serial console only: there is # no golden screenshot of this disk. - { version: "6.5.22", url: "https://origami-dist.irix.fans/irix/6.5.22/indy-a22c2ba9.chd", sha256: a22c2ba9abc3bd6c61056304c965c39074c0ef00e06f7c9aabd1ec25a1865482, steps: tests/irix/boot-irix65.toml } + # Stock IRIX 6.5.30 O2 install, 879 MB, on the O2's rev 4.18 PROM (the + # IRIX installer's ip32prom.image). Serial console only, as above. The + # others leave machine and the PROM empty and boot an Indy. + - { version: "6.5.30", url: "https://origami-dist.irix.fans/irix/6.5.30/o2-34c49a43.chd", sha256: 34c49a43a794e1391f36eab2cb6e10115dbbbe39e5b4a2d0cc6512d47ff235fc, steps: tests/irix/boot-irix6530-o2.toml, machine: o2_ip32, prom_url: "https://origami-dist.irix.fans/prom/ip32prom-4.18.image", prom_sha256: 11230c353cb14fcc50ede286df228e169e32fc9e5a2206276ae29c259fafb23c } defaults: run: shell: bash @@ -449,8 +454,18 @@ jobs: fi ls -la boot-image + - name: Download the PROM image + if: ${{ matrix.irix.prom_url }} + run: | + curl -fL --retry 3 --retry-delay 10 -o boot-image/prom.image "${{ matrix.irix.prom_url }}" + got=$({ shasum -a 256 2>/dev/null || sha256sum; } < boot-image/prom.image | cut -d' ' -f1) + if [ "$got" != "${{ matrix.irix.prom_sha256 }}" ]; then + echo "::error::boot-image/prom.image sha256 $got, expected ${{ matrix.irix.prom_sha256 }}" + exit 1 + fi + - name: Boot IRIX ${{ matrix.irix.version }} - run: core/iris-ci boot-test boot-image/disk.chd ${{ matrix.irix.steps && format('--steps {0}', matrix.irix.steps) || '' }} --out boot-test --timeout "$BOOT_TIMEOUT" + run: core/iris-ci boot-test boot-image/disk.chd ${{ matrix.irix.steps && format('--steps {0}', matrix.irix.steps) || '' }} ${{ matrix.irix.machine && format('--machine {0} --prom boot-image/prom.image', matrix.irix.machine) || '' }} --out boot-test --timeout "$BOOT_TIMEOUT" # boot-test writes each check as a JUnit case, so a failed run is # annotated with the check that failed. diff --git a/TESTING.md b/TESTING.md index 7680abdf5..f9f8060c9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -326,10 +326,11 @@ space; a skipped test (`# SKIP`) reports `ok`. `build.yml` boots every IRIX image with each CLI build, on Linux, macOS and Windows: a platform by image matrix in which each image is listed once, from a -pinned, checksummed stock Indy disk. IRIX 5.3 boots on the serial and graphics +pinned, checksummed stock disk. IRIX 5.3 boots on the serial and graphics consoles ([clanker/testing/ci-irix53-disk.md](clanker/testing/ci-irix53-disk.md)); -IRIX 6.5.22 boots on the serial console only, for want of a golden screenshot. -6.5.30 has no Indy disk published, so it is not booted. +IRIX 6.5.22 and 6.5.30 boot on the serial console only, for want of a golden +screenshot. No 6.5.30 Indy disk is published, so 6.5.30 boots an O2 (`--machine +o2_ip32`) on its own PROM image, pinned in the matrix. `iris-ci boot-test` seeds NVRAM on a first headless run, boots with the console on serial and waits up to 15 minutes for `login:`, then does the same with the diff --git a/clanker/testing/ci-unattended-irix-boot.md b/clanker/testing/ci-unattended-irix-boot.md index a51ce620f..c2991994b 100644 --- a/clanker/testing/ci-unattended-irix-boot.md +++ b/clanker/testing/ci-unattended-irix-boot.md @@ -12,6 +12,13 @@ directory; `--steps` and `--golden` replace them. It starts its own iris, so run it as `iris-ci boot-test --out ` with `iris` beside `iris-ci` or named with `--iris`. +`--machine` names the profile the disk was installed on (`indy_ip24` unless set) +and `--prom` the PROM image for one with none built in. The O2 has none, and its +Command Monitor prompts with `> ` where the Indy's prints `>> `. Its settings +live in the PROM's flash, which `nvram` names and `rtc-save` writes. The stock +6.5.30 O2 disk (`tests/irix/boot-irix6530-o2.toml`) reached `login:` on serial +170 s after start, on a loaded 16-thread host. + ## A fresh NVRAM can't boot anything With no `nvram.bin` (or a bad checksum) the PROM prints `NVRAM checksum is diff --git a/src/ip32.rs b/src/ip32.rs index 6dd5c19d8..1a303d75f 100644 --- a/src/ip32.rs +++ b/src/ip32.rs @@ -663,6 +663,11 @@ impl Ip32Bus { self.crime.regs.interrupting() } + /// The file the flash is kept in, if one was named. + pub fn flash_path(&self) -> Option<&std::path::Path> { + self.flash_file.as_deref() + } + /// Write the flash out, if a file was named for it. pub fn save_flash(&self) { let Some(path) = &self.flash_file else { return }; diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index 795a46255..d498832e2 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -834,6 +834,17 @@ impl Workstation for O2Machine { self.controller_up.store(true, Ordering::SeqCst); } fn monitor_addr(&self) -> Option { self.monitor.bound_addr() } + /// The PROM's flash, where `setenv` keeps its settings, and the RTC's RAM, + /// written out now. The flash goes only to the file `nvram` names. + fn save_nvram(&self, path: Option<&str>) -> Result { + if path.is_some() { + return Err("the O2 saves its flash only to the file `nvram` names".into()); + } + let file = self.bus.flash_path().ok_or("no nvram file configured")?; + self.bus.save_flash(); + self.bus.save_rtc(); + Ok(file.display().to_string()) + } fn lifecycle(&self) -> crate::machine_state::GuestState { self.lifecycle } fn set_lifecycle(&mut self, state: crate::machine_state::GuestState) { self.lifecycle = state; } @@ -1292,6 +1303,22 @@ mod drop_tests { let _ = std::fs::remove_dir_all(&dir); } + /// `iris-ci rtc-save` on an O2 writes the PROM's flash to the `nvram` + /// file now, rather than failing as a machine with no NVRAM does. + #[test] + fn rtc_save_writes_the_flash_to_the_nvram_file() { + let dir = scratch("o2-save-nvram"); + let cfg = config(&dir, MachineProfile::O2Ip32, GraphicsBoard::Newport); + let nvram = std::path::PathBuf::from(&cfg.nvram); + let machine = big_stack(move || O2Machine::new(&cfg).expect("O2")); + assert!(!nvram.exists()); + assert_eq!(Workstation::save_nvram(&machine, None), Ok(nvram.display().to_string())); + assert_eq!(std::fs::metadata(&nvram).unwrap().len(), PROM_SIZE as u64); + assert!(Workstation::save_nvram(&machine, Some("elsewhere.bin")).is_err()); + drop(machine); + let _ = std::fs::remove_dir_all(&dir); + } + /// `[nvme]` puts a card in the "PCI option" slot and records its disk; /// the bus's disk flush reaches its namespace. #[test] diff --git a/src/iris_ci_boot.rs b/src/iris_ci_boot.rs index 5a04fe13b..ac6dedb9b 100644 --- a/src/iris_ci_boot.rs +++ b/src/iris_ci_boot.rs @@ -34,6 +34,13 @@ pub struct Args { /// A screenshot to match in place of the one each graphics boot names. #[arg(long)] golden: Option, + /// The machine the disk was installed on, by its `[machine] profile` name. + /// The disk is a stock install for one machine and boots only there. + #[arg(long, default_value = "indy_ip24")] + machine: String, + /// The PROM image, for a machine with no PROM built in (the O2). + #[arg(long)] + prom: Option, /// Seconds each boot may take to reach its login. #[arg(long, default_value_t = 900)] timeout: u64, @@ -96,6 +103,15 @@ pub fn run(args: Args) -> Result<()> { }); } let abs = |p: &Path| std::path::absolute(p).map_err(|e| local(p, e)); + let machine = iris::config::MachineProfile::from_name(&args.machine).map_err(Error::Local)?; + let prom = match (&args.prom, machine.has_embedded_prom()) { + (Some(p), _) => Some(abs(p)?), + (None, true) => None, + (None, false) => return Err(Error::Local(format!("{} has no PROM built in: name one with --prom", machine.name()))), + }; + if let Some(p) = prom.as_ref().filter(|p| !p.is_file()) { + return Err(Error::Local(format!("{}: no such PROM image", p.display()))); + } let disk = abs(&args.disk)?; if !disk.is_file() { return Err(Error::Local(format!("{}: no such disk image", disk.display()))); @@ -115,13 +131,14 @@ pub fn run(args: Args) -> Result<()> { } let socket = ci_socket(); - std::fs::write(out.join("boot-test.toml"), config(&disk, &socket)).map_err(|e| local(&out, e))?; + std::fs::write(out.join("boot-test.toml"), config(&disk, &socket, machine.name(), prom.as_deref())).map_err(|e| local(&out, e))?; let mut t = Test { out: out.clone(), iris, opts: Opts { socket: PathBuf::from(socket), json: false, quiet: true }, child: None, + prompt: monitor_prompt(machine), check: String::new(), cases: Vec::new(), failed: false, @@ -153,6 +170,13 @@ fn verdict(aborted: Step, failed: bool) -> Result<()> { } } +/// The Command Monitor's prompt. The O2's PROM prints a single `>`; the +/// others print `>>`. Waiting for `> ` on all of them would also stop at any +/// `> ` inside a listing that precedes the prompt. +fn monitor_prompt(machine: iris::config::MachineProfile) -> &'static str { + if machine == iris::config::MachineProfile::O2Ip32 { "> " } else { ">> " } +} + /// The CI socket: TCP on Windows, and elsewhere a Unix socket in the host's /// temporary directory, as iris's own default is. A path under the output /// directory could pass macOS's 104-byte limit on a Unix socket path. @@ -164,9 +188,10 @@ fn ci_socket() -> String { } } -/// The iris config of every run: the disk on SCSI 1 with an overlay, the -/// NVRAM and the serial mirror in the working directory. -fn config(disk: &Path, socket: &str) -> String { +/// The iris config of every run: the machine and its PROM, the disk on SCSI 1 +/// with an overlay, the NVRAM (the PROM's flash on an O2) and the serial mirror +/// in the working directory. +fn config(disk: &Path, socket: &str, machine: &str, prom: Option<&Path>) -> String { let mut scsi1 = toml::Table::new(); // Forward slashes in TOML work on every host, Windows included. scsi1.insert("path".into(), disk.to_string_lossy().replace('\\', "/").into()); @@ -174,7 +199,13 @@ fn config(disk: &Path, socket: &str) -> String { scsi1.insert("overlay".into(), true.into()); let mut scsi = toml::Table::new(); scsi.insert("1".into(), scsi1.into()); + let mut m = toml::Table::new(); + m.insert("profile".into(), machine.into()); let mut t = toml::Table::new(); + t.insert("machine".into(), m.into()); + if let Some(p) = prom { + t.insert("prom".into(), p.to_string_lossy().replace('\\', "/").into()); + } t.insert("no_audio".into(), true.into()); t.insert("nvram".into(), "nvram.bin".into()); t.insert("serial_log".into(), "serial.log".into()); @@ -223,6 +254,8 @@ struct Test { iris: PathBuf, opts: Opts, child: Option, + /// What the PROM's Command Monitor prints when it wants a line. + prompt: &'static str, /// The case a failure that ends the test is filed under. check: String, cases: Vec, @@ -345,7 +378,7 @@ impl Test { /// printed comes back. fn monitor(&mut self, line: &str) -> Step { let sent = send(&self.opts, "serial-send", json!({"data": format!("{line}\r")})); - match sent.is_ok().then(|| self.prom_wait(">> ", PROMPT_WAIT)).flatten() { + match sent.is_ok().then(|| self.prom_wait(self.prompt, PROMPT_WAIT)).flatten() { Some(text) => Ok(text), None => { self.dump(&self.out.join("phase-a.log")); @@ -372,7 +405,7 @@ impl Test { } // Enter Command Monitor. let _ = send(&self.opts, "serial-send", json!({"data": "5\r"})); - if self.prom_wait(">> ", PROMPT_WAIT).is_none() { + if self.prom_wait(self.prompt, PROMPT_WAIT).is_none() { self.dump(&self.out.join("phase-a.log")); return Err(self.abort("phase A: no Command Monitor prompt".into())); } @@ -555,6 +588,7 @@ mod tests { iris: PathBuf::new(), opts: Opts { socket: PathBuf::new(), json: false, quiet: true }, child: None, + prompt: ">> ", check: String::new(), cases: Vec::new(), failed: false, @@ -613,11 +647,26 @@ mod tests { #[test] fn the_config_names_the_disk_whatever_its_path_holds() { let disk = Path::new("C:\\a \"b\"\\irix.chd"); - let t: toml::Table = toml::from_str(&config(disk, "127.0.0.1:19851")).unwrap(); + let t: toml::Table = toml::from_str(&config(disk, "127.0.0.1:19851", "indy_ip24", None)).unwrap(); let scsi1 = &t["scsi"]["1"]; assert_eq!(scsi1["path"].as_str(), Some("C:/a \"b\"/irix.chd")); assert_eq!((scsi1["overlay"].as_bool(), t["ci"].as_bool()), (Some(true), Some(true))); assert_eq!(t["ci_socket"].as_str(), Some("127.0.0.1:19851")); + assert_eq!(t["machine"]["profile"].as_str(), Some("indy_ip24")); + assert!(!t.contains_key("prom")); + } + + #[test] + fn the_o2_prom_prompts_with_one_angle_bracket() { + use iris::config::MachineProfile::*; + assert_eq!((monitor_prompt(O2Ip32), monitor_prompt(IndyIp24)), ("> ", ">> ")); + } + + #[test] + fn an_o2_config_names_the_machine_and_its_prom() { + let t: toml::Table = toml::from_str(&config(Path::new("d.chd"), "s", "o2_ip32", Some(Path::new(r"C:\p\ip32prom.bin")))).unwrap(); + assert_eq!(t["machine"]["profile"].as_str(), Some("o2_ip32")); + assert_eq!(t["prom"].as_str(), Some("C:/p/ip32prom.bin")); } /// The built-in file: the same seeded variables as before, a serial and a @@ -642,6 +691,19 @@ mod tests { assert_eq!((label.as_str(), img.width > 0), ("indy-irix53.png", true)); } + /// The O2's file seeds the same variables as the Indy's and checks only + /// the serial login: no golden screenshot of that disk exists. + #[test] + fn the_o2_boot_is_the_serial_login_with_the_indys_seeding() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/irix"); + let o2 = guest_steps::load(&dir.join("boot-irix6530-o2.toml")).unwrap(); + let indy = guest_steps::parse(BOOT_TOML, "boot.toml").unwrap(); + let vars = |f: &guest_steps::StepFile| f.nvram.iter().map(|v| (v.name.clone(), v.value.clone(), v.guest)).collect::>(); + assert_eq!(vars(&o2), vars(&indy)); + let boots: Vec<_> = o2.boot.iter().map(|b| (b.console.as_str(), b.serial.as_deref(), b.screen.is_some())).collect(); + assert_eq!(boots, [("d", Some("login:"), false)]); + } + #[test] fn a_screenshot_is_found_beside_the_steps_file_or_built_in_and_not_otherwise() { let dir = std::env::temp_dir().join(format!("iris-ci-golden-{}", std::process::id())); diff --git a/tests/irix/boot-irix6530-o2.toml b/tests/irix/boot-irix6530-o2.toml new file mode 100644 index 000000000..31f279eb0 --- /dev/null +++ b/tests/irix/boot-irix6530-o2.toml @@ -0,0 +1,28 @@ +# Read by `iris-ci boot-test --steps` for the stock IRIX 6.5.30 O2 disk. The +# same seeding as boot.toml, on the O2's PROM; serial console only, because no +# golden screenshot of this disk exists. + +[[nvram]] +name = "SystemPartition" +value = "scsi(0)disk(1)rdisk(0)partition(8)" +guest = true + +[[nvram]] +name = "OSLoadPartition" +value = "scsi(0)disk(1)rdisk(0)partition(0)" +guest = true + +[[nvram]] +name = "OSLoadFilename" +value = "/unix" +guest = true + +[[nvram]] +name = "AutoLoad" +value = "Yes" + +[[boot]] +name = "serial console: login prompt" +console = "d" +quit = "iris quits when asked, serial boot" +serial = "login:"