From dba3a8e0471f89f40d74d0fe5048b9db478e54fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 20:41:51 +0000 Subject: [PATCH 1/9] Take DHCP and ntpdate off the boot path to sshd rcorder puts both in front of sshd and neither does anything useful here. The lease never changes under user mode networking, so freeze the one this build was given into the static configuration. ntpdate corrects an offset that is already ~0, since the emulated RTC is seeded from the host clock; ntpd stays, with -g to step a large offset if one appears. Also let the runner user log in without a credential on every architecture, as the VAX image already does, and add a boot_timestamps variable that stamps each rc.d script as it starts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 44 ++++++++++++- changelog.md | 15 +++++ netbsd.pkr.hcl | 9 ++- readme.md | 34 ++++++++-- resources/post_install.sh | 4 ++ resources/provision.sh | 125 ++++++++++++++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bff727..545dc60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -187,7 +187,10 @@ jobs: - name: Create File if: matrix.architecture.name != 'vax' - run: echo 'host to guest' > host_to_guest.txt + run: | + echo 'host to guest' > host_to_guest.txt + # Read by the guest to check its clock against the host's. + date -u +%s > host_epoch.txt # qemu architectures are booted by cross-platform-actions/action. - name: Test Image @@ -256,10 +259,49 @@ jobs: cat host_to_guest.txt | grep -q 'host to guest' echo 'guest to host' > guest_to_host.txt + # No DHCP client any more (provision.sh freezes the lease), so + # assert the network it ships with works rather than trusting it. + echo 'network:' + # ifconfig lives in /sbin, which isn't in the PATH of the user the + # commands run as. + PATH="/sbin:/usr/sbin:$PATH" + [ ! -f /var/run/dhcpcd.pid ] + ifconfig -l + netstat -rn -f inet | grep -q '^default' + grep -q '^nameserver' /etc/resolv.conf + curl -fsS -o /dev/null https://github.com + + # The clock comes from the emulated RTC now. The HTTPS fetch above + # already proves the right year, so this only catches a large skew. + echo 'clock:' + guest_epoch=$(date -u +%s) + host_epoch=$(cat host_epoch.txt) + skew=$((guest_epoch - host_epoch)) + echo "guest is ${skew}s ahead of the host" + [ "$skew" -gt -60 ] && [ "$skew" -lt 600 ] + + # The per-script boot timeline, when built with boot_timestamps. + echo 'rc log:' + sudo cat /var/run/rc.log || true + - name: Verify File Synchronization if: matrix.architecture.name != 'vax' run: cat guest_to_host.txt | grep -q 'guest to host' + # The action still logs in with a generated key, but the image accepts an + # empty password now, which is how it will log in once it stops building + # the resources disk. The VM above is still up (shutdown_vm: false), so + # assert that path directly with every other method disabled. + - name: Test Passwordless Login + if: matrix.architecture.name != 'vax' + run: | + ssh -p 2847 \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o BatchMode=yes -o PubkeyAuthentication=no \ + -o PasswordAuthentication=no -o GSSAPIAuthentication=no \ + -o ConnectTimeout=60 runner@127.0.0.1 \ + 'echo "logged in as $(whoami) without a credential"' + # cross-platform-actions/action is qemu-based and can't run a VAX # image, so boot the built image under SIMH directly: decompress the # distributed artifact to RAW (what a consumer does), autoboot the diff --git a/changelog.md b/changelog.md index 165ee12..a442baa 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- The `runner` user can log in over SSH without a credential on every + architecture, not just VAX, which lets a consumer stop building the FAT + resources disk that carries a generated key +- New `boot_timestamps` build variable, which makes `/etc/rc` print a + timestamped line to the console as each `rc.d` script starts + +### Changed +- Freeze the address the hypervisor hands out into the static network + configuration and disable the DHCP client, taking it off the boot path to + `sshd` +- Don't run `ntpdate` at boot. It blocks the boot on network round trips to + correct an offset that is already close to zero, since the emulated RTC is + seeded from the host clock. `ntpd` stays enabled, now with `-g` + ### Fixed - Stop the boot-time `ntpdate` being able to stall a NetBSD/VAX guest indefinitely. It runs inline in the boot sequence, ahead of `sshd`, and the diff --git a/netbsd.pkr.hcl b/netbsd.pkr.hcl index 9d8838c..3f8c5dd 100644 --- a/netbsd.pkr.hcl +++ b/netbsd.pkr.hcl @@ -169,6 +169,12 @@ variable "package_repository" { description = "The binary package repository to install the packages from. An empty value keeps the one the installer configured" } +variable "boot_timestamps" { + default = false + type = bool + description = "Print a timestamped line to the console as every rc.d script starts, to attribute the boot time to individual scripts" +} + locals { iso_target_extension = "iso" iso_target_path = "packer_cache" @@ -388,7 +394,8 @@ build { environment_vars = [ "SECONDARY_USER=${var.secondary_user_username}", "BOOT_CONSOLE=${var.boot_console}", - "PACKAGE_REPOSITORY=${var.package_repository}" + "PACKAGE_REPOSITORY=${var.package_repository}", + "BOOT_TIMESTAMPS=${var.boot_timestamps}" ] } diff --git a/readme.md b/readme.md index e602e4e..b97ff44 100644 --- a/readme.md +++ b/readme.md @@ -43,6 +43,30 @@ Except for the root user, there's one additional user, `runner`, which is the user that will be running the commands in the GitHub action. This user is allowed use `sudo` without a password. +`runner` also has an empty password, which `sshd` and the PAM stack are +configured to accept, so the image can be logged into over SSH without a +credential and without a prompt. Only `runner` is passwordless; `root` keeps the +password set during installation. + +## Boot Time + +The images are configured to reach a reachable `sshd` as quickly as possible, +because a consumer waits for that on every job: + +* The address the hypervisor hands out is frozen into the static network + configuration at build time and the DHCP client is disabled. Under QEMU's user + mode networking the lease never changes, and `dhcpcd` is ordered before the + `NETWORKING` milestone, which is on the path to `sshd`. +* `ntpdate` is not run at boot. Its `rc.d` script runs `ntpdate(8)` inline, so + the whole boot waits for its network round trips, and it corrects an offset + that is already close to zero: the hypervisor seeds the emulated RTC from the + host clock, and the kernel reads it as UTC. `ntpd` stays enabled, with `-g` so + it can step a large initial offset if one ever exists. + +Building with `-var boot_timestamps=true` makes `/etc/rc` print a timestamped +line to the console as each `rc.d` script starts, which attributes the boot time +to individual scripts. The same lines are logged to `/var/run/rc.log`. + ## Architectures and Versions The following architectures and versions are supported: @@ -125,12 +149,10 @@ That includes generating the SSH host keys on the build host and installing them into the image, which would otherwise cost around 20 minutes of RSA key generation at the emulated VAX's ~1 MIPS on the first boot. -Since the FAT resources disk that delivers the SSH key to every other port -can't be mounted on NetBSD/VAX (`msdosfs` is unavailable there), the VAX image -is logged into without a key. The `runner` user has an empty password, and both -`sshd` and the PAM stack are configured to accept it, so the login needs no -credential and not even a prompt. Only `runner` is passwordless; `root` keeps -the password set during installation. +The FAT resources disk that delivers a generated SSH key to the other ports +can't be mounted on NetBSD/VAX (`msdosfs` is unavailable there), which is why the +passwordless login described above exists. Every architecture now accepts it, so +the resources disk is no longer the only way in. ## Contributing diff --git a/resources/post_install.sh b/resources/post_install.sh index c759283..4d9c0cb 100755 --- a/resources/post_install.sh +++ b/resources/post_install.sh @@ -4,9 +4,13 @@ set -eux set -o pipefail configure_ssh() { + # PermitEmptyPasswords is the first of the two gates the empty password of + # the secondary user has to pass; see setup_passwordless_login in + # provision.sh. cat <> /mnt/etc/ssh/sshd_config PermitRootLogin yes PasswordAuthentication yes +PermitEmptyPasswords yes PubkeyAuthentication yes UseDNS no AcceptEnv * diff --git a/resources/provision.sh b/resources/provision.sh index d52aa66..ef3c0f1 100755 --- a/resources/provision.sh +++ b/resources/provision.sh @@ -2,6 +2,10 @@ set -exu +# Where configure_network keeps a copy of the resolver configuration that +# dhcpcd doesn't know about, and configure_boot_scripts restores it from. +RESOLV_BACKUP='/etc/resolv.conf.cpa' + setup_path() { PATH="/sbin:/usr/sbin:$PATH" export PATH @@ -76,9 +80,125 @@ configure_boot_console() { fi } +# User mode networking hands out the same lease every boot, and rcorder puts +# dhcpcd in front of sshd. Freeze the lease this build was given, read from the +# running system so the image keeps whatever the hypervisor hands out. +configure_network() { + interface=$(route -n get default | awk '$1 == "interface:" { print $2; exit }') + gateway=$(route -n get default | awk '$1 == "gateway:" { print $2; exit }') + + # NetBSD's ifconfig prints either "inet 10.0.2.15/24 ..." or, on the older + # releases, "inet 10.0.2.15 netmask 0xffffff00 ...". Both spellings are valid + # as ifconfig arguments, so keep whichever this release produced. + address=$(ifconfig "$interface" inet | awk '$1 == "inet" { + if ($2 ~ /\//) { print $2; exit } + for (i = 3; i < NF; i++) + if ($i == "netmask") { print $2 " netmask " $(i + 1); exit } + print $2 + exit + }') + + nameservers=$(awk '$1 == "nameserver" { print }' /etc/resolv.conf) + + # An empty value here would ship an image that never becomes reachable, and + # the only symptom would be the consumer's SSH timing out half an hour later. + # Fail the build while the reason is still on screen. + [ -n "$interface" ] && [ -n "$gateway" ] && [ -n "$address" ] + [ -n "$nameservers" ] + + # rc.conf rather than /etc/ifconfig.: the variable wins over the file, and + # appending overrides what the installer set. Both settings are needed -- + # `dhcpcd=NO` disables the service, but /etc/rc.d/network runs dhcpcd itself + # for an interface still configured as `dhcp`, precisely when it is disabled. + rm -f "/etc/ifconfig.$interface" + + cat <> /etc/rc.conf +ifconfig_$interface="$address" +defaultroute="$gateway" +dhcpcd=NO +EOF + + echo "$nameservers" > /etc/resolv.conf + + # dhcpcd is disabled from the next boot on, but it's still running while this + # image is built, and stopping it during the final poweroff restores the + # pre-DHCP /etc/resolv.conf. Keep a copy it doesn't know about, which + # configure_boot_scripts restores at boot if that happened. + cp /etc/resolv.conf "$RESOLV_BACKUP" +} + +# ntpdate's rc.d script runs it inline, so the boot waits for network round +# trips to correct an offset that is already ~0: QEMU seeds the emulated RTC +# from the host clock. ntpd stays -- a daemon costs nothing on the path to +# sshd, and -g steps a large initial offset the way ntpdate would have. +configure_time_sync() { + cat <> /etc/rc.conf +ntpdate=NO +ntpd=YES +ntpd_flags="-g" +EOF +} + +# Let the secondary user log in over SSH with no credential, the way the VAX +# image does (resources/post_install_vax.sh). Three things have to line up, and +# the login fails if any one is missing: +# +# 1. PermitEmptyPasswords, or sshd passes PAM_DISALLOW_NULL_AUTHTOK +# (post_install.sh). +# 2. `nullok` on pam_unix's auth line, or it substitutes "*" for the empty +# hash. +# 3. An actually empty password field; sysinst set one during the install. +# +# Only the secondary user is passwordless; root keeps its install password. The +# image is a throwaway CI guest, reachable only through the consumer's own port +# forward. +setup_passwordless_login() { + sed -i -E '/^auth[[:space:]]+required[[:space:]]+pam_unix\.so/ s/$/ nullok/' \ + /etc/pam.d/sshd + + sed -i -E "s/^(${SECONDARY_USER}):[^:]*:/\1::/" /etc/master.passwd + pwd_mkdb -p /etc/master.passwd + + # A substitution that matches nothing leaves sed successful, so assert the + # result instead of trusting it. + grep -q '^auth.*pam_unix\.so.*nullok' /etc/pam.d/sshd + grep -q "^${SECONDARY_USER}::" /etc/master.passwd +} + +# Stamp each rc.d script as it starts, so the time between init and sshd can be +# attributed to individual scripts. One second resolution: NetBSD's date(1) has +# no sub-second conversion. The line goes in front of the cmd-name metadata, +# never between run_rc_script and the cmd-status line reporting its $?. +configure_boot_timestamps() { + [ "${BOOT_TIMESTAMPS:-}" = 'true' ] || return 0 + + awk ' + /print_rc_metadata "cmd-name:\$_rc_elem"/ { + print "\t\tprint_rc_normal \"cpa-boot-timestamp $(date +%s) $_rc_elem\"" + patched = 1 + } + { print } + END { if (!patched) exit 1 } + ' /etc/rc > /tmp/rc.timestamps + + # Redirect into the existing file rather than moving over it, to keep the + # mode and ownership of /etc/rc. + cat /tmp/rc.timestamps > /etc/rc + rm -f /tmp/rc.timestamps +} + +# The resources disk and the key it carries are still how the action logs in. +# Once it stops delivering one (the image now accepts a passwordless login, see +# setup_passwordless_login) both this hook and the msdosfs dependency it brings +# can go away. configure_boot_scripts() { cat <> /etc/rc.local RESOURCES_MOUNT_PATH='/mnt/resources' +RESOLV_BACKUP='$RESOLV_BACKUP' + +restore_resolv_conf() { + [ -s /etc/resolv.conf ] || cp "\$RESOLV_BACKUP" /etc/resolv.conf +} mount_resources_disk() { # get the last disk @@ -99,6 +219,7 @@ install_authorized_keys() { fi } +restore_resolv_conf mount_resources_disk install_authorized_keys EOF @@ -115,6 +236,10 @@ setup_path # behind a step that can fail because of a mirror. configure_boot_flags configure_boot_console +configure_boot_timestamps +configure_network +configure_time_sync +setup_passwordless_login configure_boot_scripts set_hostname From 44ca568d9226c9385144169df663015715d6a5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:39:23 +0000 Subject: [PATCH 2/9] Don't wait for IPv6 duplicate address detection at boot /etc/rc.d/network was 6 of the 7 seconds the rc sequence took. It ends with `ifconfig $ifconfig_wait_dad_flags`, whose default `-W 5` waits for the `detached` flag to clear -- which nothing can satisfy, since the consumer runs the guest with IPv6 off and no router is ever advertised. This removes work from the boot without yet making a job start sooner: something after rc absorbs whatever rc gives back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 6 ++++++ changelog.md | 3 +++ readme.md | 5 +++++ resources/provision.sh | 6 ++++++ 4 files changed, 20 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 545dc60..65b69c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,6 +271,12 @@ jobs: grep -q '^nameserver' /etc/resolv.conf curl -fsS -o /dev/null https://github.com + # /etc/rc.d/network no longer waits for an IPv6 duplicate address + # detection that nothing can satisfy. Assert the wait didn't run: + # it announces itself on the console, and /etc/rc logs that. + ifconfig "$(route -n get default | awk '$1 == "interface:" { print $2 }')" inet + ! sudo grep -q 'duplicate address detection' /var/run/rc.log + # The clock comes from the emulated RTC now. The HTTPS fetch above # already proves the right year, so this only catches a large skew. echo 'clock:' diff --git a/changelog.md b/changelog.md index a442baa..0a816e8 100644 --- a/changelog.md +++ b/changelog.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't run `ntpdate` at boot. It blocks the boot on network round trips to correct an offset that is already close to zero, since the emulated RTC is seeded from the host clock. `ntpd` stays enabled, now with `-g` +- Don't wait for IPv6 duplicate address detection in `/etc/rc.d/network`. Part of + that wait can never be satisfied behind user mode networking with IPv6 + switched off, and it was the largest single item in the boot ### Fixed - Stop the boot-time `ntpdate` being able to stall a NetBSD/VAX guest diff --git a/readme.md b/readme.md index b97ff44..39d2f90 100644 --- a/readme.md +++ b/readme.md @@ -62,6 +62,11 @@ because a consumer waits for that on every job: that is already close to zero: the hypervisor seeds the emulated RTC from the host clock, and the kernel reads it as UTC. `ntpd` stays enabled, with `-g` so it can step a large initial offset if one ever exists. +* `/etc/rc.d/network` doesn't wait for IPv6 duplicate address detection + (`ifconfig_wait_dad_flags=""`). Its default `-w 15 -W 5` waits up to 5 seconds + for the `detached` flag to clear, which never happens when the guest runs + behind user mode networking with IPv6 switched off, so it spent that time on + every boot. Building with `-var boot_timestamps=true` makes `/etc/rc` print a timestamped line to the console as each `rc.d` script starts, which attributes the boot time diff --git a/resources/provision.sh b/resources/provision.sh index ef3c0f1..df7c38b 100755 --- a/resources/provision.sh +++ b/resources/provision.sh @@ -112,10 +112,16 @@ configure_network() { # for an interface still configured as `dhcp`, precisely when it is disabled. rm -f "/etc/ifconfig.$interface" + # /etc/rc.d/network ends with `ifconfig $ifconfig_wait_dad_flags`, defaulting + # to `-w 15 -W 5`. The `-W 5` waits for the `detached` flag to clear, which + # nothing can satisfy: the consumer runs the guest with IPv6 off, so no router + # is ever advertised. That was 6 of the 7 seconds rc took. An empty value + # makes the wait a no-op; `-w 1` would keep it bounded instead. cat <> /etc/rc.conf ifconfig_$interface="$address" defaultroute="$gateway" dhcpcd=NO +ifconfig_wait_dad_flags="" EOF echo "$nameservers" > /etc/resolv.conf From 72f99bef71d0d6c3a624a9296088be559ec96b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:39:23 +0000 Subject: [PATCH 3/9] Stamp the end of the rc sequence too A stamp per script start leaves the last script's duration unaccounted for, so the end of the sequence is only an inference. Stamp the end of the loop as well. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- resources/provision.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/provision.sh b/resources/provision.sh index df7c38b..853e9e7 100755 --- a/resources/provision.sh +++ b/resources/provision.sh @@ -174,7 +174,8 @@ setup_passwordless_login() { # Stamp each rc.d script as it starts, so the time between init and sshd can be # attributed to individual scripts. One second resolution: NetBSD's date(1) has # no sub-second conversion. The line goes in front of the cmd-name metadata, -# never between run_rc_script and the cmd-status line reporting its $?. +# never between run_rc_script and the cmd-status line reporting its $?. The end +# of the loop is stamped too, so the last script's own duration is accounted for. configure_boot_timestamps() { [ "${BOOT_TIMESTAMPS:-}" = 'true' ] || return 0 @@ -183,8 +184,12 @@ configure_boot_timestamps() { print "\t\tprint_rc_normal \"cpa-boot-timestamp $(date +%s) $_rc_elem\"" patched = 1 } + /print_rc_metadata "end:/ { + print "\tprint_rc_normal \"cpa-boot-timestamp $(date +%s) -end-of-rc\"" + patched_end = 1 + } { print } - END { if (!patched) exit 1 } + END { if (!patched || !patched_end) exit 1 } ' /etc/rc > /tmp/rc.timestamps # Redirect into the existing file rather than moving over it, to keep the From 27800c97c5532b5f6c7163a5f0bb923afe519161 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 18:41:15 +0000 Subject: [PATCH 4/9] Don't do duplicate address detection at boot Removing the wait moved the same six seconds from rc to after rc, rather than saving them. An address is unusable while the kernel probes for another host holding it: sshd was listening at +4s but dropped every connection until +11s, which the client reports as a timeout during the banner exchange. Set the probe count to zero instead. The address comes from the hypervisor's user mode network, a private segment with one guest on it, so there is no second host that could hold it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 13 +++++++++---- changelog.md | 8 +++++--- readme.md | 13 ++++++++----- resources/provision.sh | 24 ++++++++++++++++++++---- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65b69c4..4bb298d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,10 +271,15 @@ jobs: grep -q '^nameserver' /etc/resolv.conf curl -fsS -o /dev/null https://github.com - # /etc/rc.d/network no longer waits for an IPv6 duplicate address - # detection that nothing can satisfy. Assert the wait didn't run: - # it announces itself on the console, and /etc/rc logs that. - ifconfig "$(route -n get default | awk '$1 == "interface:" { print $2 }')" inet + # Duplicate address detection is off, so the address is usable as + # soon as it is configured and rc doesn't wait for it. Assert the + # probe count, the absence of a `tentative` flag, and that the wait + # never ran -- it announces itself on the console, which rc logs. + [ "$(sysctl -n net.inet.ip.dad_count)" -eq 0 ] + [ "$(sysctl -n net.inet6.ip6.dad_count)" -eq 0 ] + interface=$(route -n get default | awk '$1 == "interface:" { print $2 }') + ifconfig "$interface" inet + ! ifconfig "$interface" inet | grep -q tentative ! sudo grep -q 'duplicate address detection' /var/run/rc.log # The clock comes from the emulated RTC now. The HTTPS fetch above diff --git a/changelog.md b/changelog.md index 0a816e8..cfda4d5 100644 --- a/changelog.md +++ b/changelog.md @@ -19,9 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't run `ntpdate` at boot. It blocks the boot on network round trips to correct an offset that is already close to zero, since the emulated RTC is seeded from the host clock. `ntpd` stays enabled, now with `-g` -- Don't wait for IPv6 duplicate address detection in `/etc/rc.d/network`. Part of - that wait can never be satisfied behind user mode networking with IPv6 - switched off, and it was the largest single item in the boot +- Don't do duplicate address detection at all, and don't wait for it in + `/etc/rc.d/network`. An address is unusable while it is probed, so `sshd` + answered nothing for several seconds after it started listening. There is no + second host on the hypervisor's user mode network that could hold the + address ### Fixed - Stop the boot-time `ntpdate` being able to stall a NetBSD/VAX guest diff --git a/readme.md b/readme.md index 39d2f90..5cee43d 100644 --- a/readme.md +++ b/readme.md @@ -62,11 +62,14 @@ because a consumer waits for that on every job: that is already close to zero: the hypervisor seeds the emulated RTC from the host clock, and the kernel reads it as UTC. `ntpd` stays enabled, with `-g` so it can step a large initial offset if one ever exists. -* `/etc/rc.d/network` doesn't wait for IPv6 duplicate address detection - (`ifconfig_wait_dad_flags=""`). Its default `-w 15 -W 5` waits up to 5 seconds - for the `detached` flag to clear, which never happens when the guest runs - behind user mode networking with IPv6 switched off, so it spent that time on - every boot. +* Duplicate address detection is off (`net.inet.ip.dad_count=0`, + `net.inet6.ip6.dad_count=0`), and `/etc/rc.d/network` doesn't wait for it + (`ifconfig_wait_dad_flags=""`). An address is unusable while it is being + probed, so the kernel drops everything addressed to it and `sshd` answers + nothing even though it is already listening -- which is what gated a job + starting. NetBSD probes for IPv4 too (RFC 5227) and the schedule takes several + seconds. There is nothing to find: the address comes from the hypervisor's own + user mode network, with exactly one guest on it. Building with `-var boot_timestamps=true` makes `/etc/rc` print a timestamped line to the console as each `rc.d` script starts, which attributes the boot time diff --git a/resources/provision.sh b/resources/provision.sh index 853e9e7..82615a6 100755 --- a/resources/provision.sh +++ b/resources/provision.sh @@ -113,10 +113,9 @@ configure_network() { rm -f "/etc/ifconfig.$interface" # /etc/rc.d/network ends with `ifconfig $ifconfig_wait_dad_flags`, defaulting - # to `-w 15 -W 5`. The `-W 5` waits for the `detached` flag to clear, which - # nothing can satisfy: the consumer runs the guest with IPv6 off, so no router - # is ever advertised. That was 6 of the 7 seconds rc took. An empty value - # makes the wait a no-op; `-w 1` would keep it bounded instead. + # to `-w 15 -W 5`. Neither wait is wanted: `-W 5` waits for the `detached` + # flag, which nothing can satisfy with IPv6 off, and waiting for `tentative` + # only moves a cost that disable_duplicate_address_detection removes instead. cat <> /etc/rc.conf ifconfig_$interface="$address" defaultroute="$gateway" @@ -133,6 +132,22 @@ EOF cp /etc/resolv.conf "$RESOLV_BACKUP" } +# An address is unusable while the kernel probes the link for another host +# holding it: it keeps the `tentative` flag and the kernel drops packets +# addressed to it, so sshd accepts nothing despite listening since rc started +# it. NetBSD does this for IPv4 too (RFC 5227) and it took around 7 seconds. +# +# Nothing can be found here -- the address comes from the hypervisor's user mode +# network, a private segment with one guest on it -- so set the probe count to +# zero, which clears the flag immediately. /etc/rc.d/sysctl runs long before +# /etc/rc.d/network, so this is in effect by the time the interface is up. +disable_duplicate_address_detection() { + cat <> /etc/sysctl.conf +net.inet.ip.dad_count=0 +net.inet6.ip6.dad_count=0 +EOF +} + # ntpdate's rc.d script runs it inline, so the boot waits for network round # trips to correct an offset that is already ~0: QEMU seeds the emulated RTC # from the host clock. ntpd stays -- a daemon costs nothing on the path to @@ -249,6 +264,7 @@ configure_boot_flags configure_boot_console configure_boot_timestamps configure_network +disable_duplicate_address_detection configure_time_sync setup_passwordless_login configure_boot_scripts From 98d58b9ab077e5c21fb538bae45eeda16859ea6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:34:31 +0000 Subject: [PATCH 5/9] Distribute the image as a bundle holding the disk and the kernel One artifact per image, netbsd--.tar.zst, holding a RAW `disk.img` and, where the release publishes one, a `kernel` for QEMU's `microvm` machine type. The members are named generically, so a consumer needs one code path per platform. The kernel is why this is a tar: that machine type cannot boot from a disk, so the consumer hands the kernel to QEMU itself and it cannot live inside the image. qcow2 goes with it. Its compression has to keep the image writable, so it loses to a solid stream, and the consumer pays that on every job: the 11.0 x86-64 image goes from 497 MiB to 268 MiB. The image's zero ranges are dug into holes first. Without that the archive carries all 12 GB and the consumer writes all 12 GB back out, which measured 47 seconds against the 4 the qcow2 conversion took. Ref: https://github.com/cross-platform-actions/action/issues/151 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 22 ++++---- build.sh | 106 ++++++++++++++++++++++++++++++++---- changelog.md | 13 +++++ netbsd.pkr.hcl | 11 ++-- readme.md | 39 +++++++++---- 5 files changed, 155 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4bb298d..5dc1287 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,7 +79,7 @@ jobs: - name: Install Dependencies run: | sudo apt update - sudo apt install -y fdisk dosfstools rsync curl python3 + sudo apt install -y fdisk dosfstools rsync curl python3 zstd # qemu only for the qemu-backed architectures; vax uses SIMH. if [ -n '${{ matrix.architecture.qemu }}' ]; then @@ -200,7 +200,7 @@ jobs: operating_system: netbsd architecture: ${{ matrix.architecture.name }} version: '${{ matrix.version }}' - image_url: http://localhost:8080/netbsd-${{ matrix.version }}-${{ matrix.architecture.name }}.qcow2 + image_url: http://localhost:8080/netbsd-${{ matrix.version }}-${{ matrix.architecture.name }}.tar.zst shutdown_vm: false shell: bash run: | @@ -314,22 +314,22 @@ jobs: 'echo "logged in as $(whoami) without a credential"' # cross-platform-actions/action is qemu-based and can't run a VAX - # image, so boot the built image under SIMH directly: decompress the - # distributed artifact to RAW (what a consumer does), autoboot the - # disk via EXPECT/SEND, wait for sshd, and run uname as the secondary - # user over ssh, the way the consumer logs in. + # image, so boot the built image under SIMH directly: unpack the + # distributed bundle (what a consumer does), autoboot the disk it holds + # via EXPECT/SEND, wait for sshd, and run uname as the secondary user + # over ssh, the way the consumer logs in. - name: Test Image (vax) if: matrix.architecture.name == 'vax' run: | set -eux - zstd -dc "output/netbsd-${{ matrix.version }}-vax.img.zst" > disk.raw + zstd -dc "output/netbsd-${{ matrix.version }}-vax.tar.zst" | tar -x # Autoboot the disk hands-free; forward host 2222 -> guest ssh. { echo "set cpu 64m" echo "set rq0 ra92" - echo "attach rq0 disk.raw" + echo "attach rq0 disk.img" echo "attach xq nat:tcp=2222:10.0.2.15:22" echo 'expect ">>>" send "boot dua0\r"; go' echo "boot cpu" @@ -416,9 +416,9 @@ jobs: run: | version="${github_ref_name#v}" - # Ship only the distributable image: the compressed RAW for vax - # (not the 1.5 GB raw .img or console logs), the qcow2 otherwise. - artifacts="${{ matrix.architecture.name == 'vax' && 'output/*.img.zst' || 'output/*.qcow2' }}" + # One artifact per architecture now: the bundle holds the disk and, + # where a release publishes one, the microvm kernel (see build.sh). + artifacts="output/*.tar.zst" # Matrix jobs run in parallel; the first to arrive creates the # draft release, the rest upload their artifacts to it. diff --git a/build.sh b/build.sh index 74258f9..312edec 100755 --- a/build.sh +++ b/build.sh @@ -59,6 +59,94 @@ download_install_media() { download_install_media || true +# Turn the image's zero ranges into holes, which is what makes `tar --sparse` +# worth asking for: tar uses SEEK_HOLE rather than looking for zeroes itself, +# and the image arrives from the builder fully allocated, so it finds none and +# archives all 12 GB. Digging first takes both ends down to the ~700 MB the +# installation uses. +# +# `fallocate` is Linux-only and works in place. Elsewhere a raw-to-raw qemu-img +# conversion skips the zero blocks and so writes a sparse file. +sparsify() { + image="$1" + + if command -v fallocate > /dev/null 2>&1; then + fallocate --dig-holes "$image" + else + qemu-img convert -f raw -O raw "$image" "$image.sparse" + mv "$image.sparse" "$image" + fi + + echo "image: $(du -h "$image" | cut -f1) on disk, \ +$(ls -l "$image" | awk '{print $5}') bytes virtual" +} + +# One artifact per image, holding a RAW `disk.img` and, where the release has +# one, a `kernel`. The members are named generically so a consumer needs one +# code path per platform. -19 --long=27 is the knee of the size/speed curve, and +# a 128 MiB window is zstd's default decompression limit, so the consumer needs +# no --long to unpack. +bundle_image() { + raw="output/netbsd-$OS_VERSION-$ARCHITECTURE.img" + bundle="output/netbsd-$OS_VERSION-$ARCHITECTURE.tar.zst" + + # Staged inside output/ so that renaming a 12 GB file stays a rename rather + # than becoming a copy onto another file system. + rm -rf output/bundle + mkdir -p output/bundle + mv "$raw" output/bundle/disk.img + + members='disk.img' + + if [ -f output/kernel ]; then + mv output/kernel output/bundle/kernel + members="$members kernel" + fi + + sparsify output/bundle/disk.img + + # shellcheck disable=SC2086 + tar --sparse -C output/bundle -cf - $members \ + | zstd -19 --long=27 -T0 -o "$bundle" -f + + rm -rf output/bundle + ls -l "$bundle" +} + +# The kernel for QEMU's `microvm` machine type. It goes beside the disk rather +# than inside the image, because that machine type cannot boot from a disk: the +# consumer passes it to QEMU with `-kernel`. +# +# Only releases with the MICROVM configuration publish one, so a release without +# it is not an error. The archive mirror is tried second, as older releases move +# there. +download_microvm_kernel() { + [ "$ARCHITECTURE" = 'x86-64' ] || return 0 + + image_architecture=$(awk -F'"' '/^ *image *=/ { print $2 }' \ + "var_files/$ARCHITECTURE.pkrvars.hcl") + target=output/kernel + mkdir -p output + + for base in \ + "https://cdn.netbsd.org/pub/NetBSD/NetBSD-$OS_VERSION" \ + "https://archive.netbsd.org/pub/NetBSD-archive/NetBSD-$OS_VERSION" + do + url="$base/$image_architecture/binary/kernel/netbsd-MICROVM.gz" + echo "microvm kernel: trying $url" + rm -f /tmp/microvm-kernel.gz + curl -fL --connect-timeout 30 -o /tmp/microvm-kernel.gz "$url" || continue + + gunzip -c /tmp/microvm-kernel.gz > "$target" + echo "microvm kernel: obtained from $url" + ls -l "$target" + return 0 + done + + echo "microvm kernel: NetBSD $OS_VERSION publishes none" + return 1 +} + # NetBSD/VAX is built by the SIMH plugin from a separate template. It # shares var_files/common.pkrvars.hcl (the user/password identity) with # the qemu builds, but not the qemu-specific layers — the VAX template @@ -108,16 +196,10 @@ if [ "$ARCHITECTURE" = "vax" ]; then "$@" \ netbsd-vax.pkr.hcl - # Compress the RAW disk image for distribution. The qemu architectures - # get compression for free from qcow2; the SIMH RAW image does not, so - # we zstd it here. Build time is irrelevant, so compress hard: -19 - # --long=27 is the knee of the size/speed curve (~90 MB from ~1.5 GB) - # and a 128 MiB window is exactly zstd's default decompression limit, - # so the consumer needs no --long flag to `zstd -d`. The consumer - # stream-decompresses back to RAW (least runtime overhead under SIMH): - # curl -sL /netbsd--vax.img.zst | zstd -dc > disk.raw - image="output/netbsd-${OS_VERSION}-vax.img" - zstd -19 --long=27 -f "$image" -o "$image.zst" + # Same bundle as the qemu architectures, so the consumer has one code path. + # SIMH attaches RAW with the least runtime overhead, so the image here is RAW + # to begin with; there is no microvm kernel for vax. + bundle_image else packer init netbsd.pkr.hcl @@ -129,4 +211,8 @@ else -var-file "var_files/$OS_VERSION/common.pkrvars.hcl" \ "$@" \ netbsd.pkr.hcl + + # Before bundling: the kernel is a member of the bundle, not its own artifact. + download_microvm_kernel || true + bundle_image fi diff --git a/changelog.md b/changelog.md index cfda4d5..91ad3c0 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Publish the kernel for QEMU's `microvm` machine type inside the image bundle, + where a NetBSD release provides one - The `runner` user can log in over SSH without a credential on every architecture, not just VAX, which lets a consumer stop building the FAT resources disk that carries a generated key @@ -13,6 +15,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 timestamped line to the console as each `rc.d` script starts ### Changed +- Distribute every image as a bundle, `netbsd--.tar.zst`, + holding a RAW `disk.img` and, where the release has one, a `kernel`. qcow2 is + gone: its own compression has to keep the image writable, so it compresses + worse than a solid stream, and the consumer pays that on every job. The 11.0 + x86-64 image goes from 497 MiB to 268 MiB + ([action#151](https://github.com/cross-platform-actions/action/issues/151)) +- Turn the image's zero ranges into holes before archiving it. The image comes + out of the builder fully allocated, and `tar --sparse` asks the file system + where the holes are rather than looking for zeroes itself, so without this + the archive carries all 12 GB and the consumer writes all 12 GB back out + when it unpacks: 47 seconds, against the 4 that converting the qcow2 took - Freeze the address the hypervisor hands out into the static network configuration and disable the DHCP client, taking it off the boot path to `sshd` diff --git a/netbsd.pkr.hcl b/netbsd.pkr.hcl index 3f8c5dd..c3947ef 100644 --- a/netbsd.pkr.hcl +++ b/netbsd.pkr.hcl @@ -181,7 +181,7 @@ locals { iso_full_target_path = "${local.iso_target_path}/${sha1(var.checksum)}.${local.iso_target_extension}" image = "NetBSD-${var.os_version}-${var.architecture.image}.${local.iso_target_extension}" - vm_name = "netbsd-${var.os_version}-${var.architecture.name}.qcow2" + vm_name = "netbsd-${var.os_version}-${var.architecture.name}.img" full_remote_path = "images/${var.os_version}/${local.image}?key=NetBSD" } @@ -191,10 +191,13 @@ source "qemu" "qemu" { memory = var.memory net_device = "virtio-net" - disk_compression = true disk_interface = "virtio" disk_size = var.disk_size - format = "qcow2" + + # RAW, because that is what gets distributed (compressed with zstd, see + # build.sh). Asking the builder for it directly avoids converting the image + # afterwards. disk_compression is a qcow2-only option, so it's gone with it. + format = "raw" headless = var.headless use_default_display = var.use_default_display @@ -346,7 +349,7 @@ source "qemu" "qemu" { ["-device", "virtio-scsi-pci"], ["-device", "scsi-hd,drive=drive0,bootindex=0"], ["-device", "scsi-cd,drive=drive1,bootindex=1"], - ["-drive", "if=none,file={{ .OutputDir }}/{{ .Name }},id=drive0,cache=writeback,discard=ignore,format=qcow2"], + ["-drive", "if=none,file={{ .OutputDir }}/{{ .Name }},id=drive0,cache=writeback,discard=ignore,format=raw"], ["-drive", "if=none,file=${local.iso_full_target_path},id=drive1,media=disk,format=raw,readonly=on"], ["-netdev", "user,id=user.0,hostfwd=tcp::{{ .SSHHostPort }}-:22,ipv6=off"] ] diff --git a/readme.md b/readme.md index 5cee43d..4f7c7f9 100644 --- a/readme.md +++ b/readme.md @@ -122,8 +122,7 @@ For the VAX architecture, which is built by [SIMH] instead of QEMU: architectures available in the above table. The above command will build the VM image and the resulting disk image will be -at the path: `output/netbsd-9.2-x86-64.qcow2`. For VAX the artifact is the -compressed RAW image instead: `output/netbsd--vax.img.zst`. +at the path: `output/netbsd--.tar.zst`. ## Additional Information @@ -134,17 +133,35 @@ image uses the serial port by default, since the QEMU `virt` machine has no display device at all. The VAX has a single console, the one SIMH itself drives, so its boot output is captured without any extra configuration. -The qcow2 format is chosen because unused space doesn't take up any space on -disk, it's compressible and easily converts the raw format. - -The VAX image is a RAW disk, the format SIMH attaches with the least runtime -overhead, so it gets none of qcow2's compression. It's compressed with zstd for -distribution instead, which brings the 1.5 GB RA92 disk down to roughly 90 MB. -The window size is kept at zstd's default decompression limit, so no extra -flags are needed to decompress it: +Every image is distributed as a single artifact, +`netbsd--.tar.zst`, holding: + +* `disk.img`, the RAW disk. Every builder is asked for RAW directly, so nothing + has to be converted afterwards. qcow2 is not used: its own compression has to + keep the image writable, so it compresses worse than a solid stream does, and a + consumer pays that difference on every job. The measured numbers are in + [action#151](https://github.com/cross-platform-actions/action/issues/151). +* `kernel`, where the release publishes one for QEMU's `microvm` machine type. + That machine type has no way to boot from a disk -- the consumer hands the + kernel to QEMU with `-kernel` -- so it can't live inside the image. Keeping it + in the same artifact means the kernel and the userland it has to match can + never disagree, and lets a consumer decide whether it can boot the fast way by + looking at what it unpacked. + +The members are named generically rather than after NetBSD or the version, so +that a consumer needs one code path for every platform that ships an image this +way. + +The image's zero ranges are turned into actual holes before it is archived, and +the tar records those holes rather than the zeroes in them, so neither end has to +read or write the full 12 GB -- only the ~700 MB the installation uses. That +matters most to the consumer, which writes the image out on every job: without +it, unpacking took 47 seconds against the 4 the qcow2 conversion needed. The +compression window is kept at zstd's default decompression limit, so no extra +flags are needed to unpack it: ``` -curl -sL /netbsd--vax.img.zst | zstd -dc > disk.raw +curl -sL /netbsd--.tar.zst | zstd -dc | tar -x ``` The VAX image is also provisioned differently. The KA655 firmware's self-test From 4a6eab919054434452743953a02496a33c058aea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:34:32 +0000 Subject: [PATCH 6/9] Stop mounting the resources disk at boot The rc.local hook mounted the last attached disk as FAT and installed the SSH key it found there as the runner's authorized key. The login needs no credential now, so there is no disk to find, and the hook was mounting whatever happened to be last. The resolver restore stays. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- changelog.md | 4 ++++ readme.md | 8 ++++---- resources/provision.sh | 29 +++-------------------------- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/changelog.md b/changelog.md index 91ad3c0..ed64130 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Removed +- The `rc.local` hook that mounted the consumer's FAT resources disk and + installed the SSH key from it. The passwordless login replaces it + ### Added - Publish the kernel for QEMU's `microvm` machine type inside the image bundle, where a NetBSD release provides one diff --git a/readme.md b/readme.md index 4f7c7f9..859f8c0 100644 --- a/readme.md +++ b/readme.md @@ -174,10 +174,10 @@ That includes generating the SSH host keys on the build host and installing them into the image, which would otherwise cost around 20 minutes of RSA key generation at the emulated VAX's ~1 MIPS on the first boot. -The FAT resources disk that delivers a generated SSH key to the other ports -can't be mounted on NetBSD/VAX (`msdosfs` is unavailable there), which is why the -passwordless login described above exists. Every architecture now accepts it, so -the resources disk is no longer the only way in. +The passwordless login described above started with VAX, which can't mount the +FAT disk the consumer used to deliver a generated SSH key on (`msdosfs` is +unavailable there). Every architecture accepts it now, so there is no key and no +disk to carry one. ## Contributing diff --git a/resources/provision.sh b/resources/provision.sh index 82615a6..dfbd6f2 100755 --- a/resources/provision.sh +++ b/resources/provision.sh @@ -213,41 +213,18 @@ configure_boot_timestamps() { rm -f /tmp/rc.timestamps } -# The resources disk and the key it carries are still how the action logs in. -# Once it stops delivering one (the image now accepts a passwordless login, see -# setup_passwordless_login) both this hook and the msdosfs dependency it brings -# can go away. +# The consumer used to deliver a generated SSH key on a FAT disk, mounted here. +# It logs in without a credential now (setup_passwordless_login), so all that is +# left of this hook is the resolver restore. configure_boot_scripts() { cat <> /etc/rc.local -RESOURCES_MOUNT_PATH='/mnt/resources' RESOLV_BACKUP='$RESOLV_BACKUP' restore_resolv_conf() { [ -s /etc/resolv.conf ] || cp "\$RESOLV_BACKUP" /etc/resolv.conf } -mount_resources_disk() { - # get the last disk - disk="/dev/\$(sysctl -n hw.disknames | grep -o '[^ ]*$')" - - if [ -n "\$disk" ]; then - mkdir -p "\$RESOURCES_MOUNT_PATH" - mount_msdos "\$disk" "\$RESOURCES_MOUNT_PATH" - fi -} - -install_authorized_keys() { - if [ -s "\$RESOURCES_MOUNT_PATH/KEYS" ]; then - mkdir -p "/home/$SECONDARY_USER/.ssh" - cp "\$RESOURCES_MOUNT_PATH/KEYS" "/home/$SECONDARY_USER/.ssh/authorized_keys" - chown "$SECONDARY_USER" "/home/$SECONDARY_USER/.ssh/authorized_keys" - chmod 600 "/home/$SECONDARY_USER/.ssh/authorized_keys" - fi -} - restore_resolv_conf -mount_resources_disk -install_authorized_keys EOF } From 1d20798b3d12dea26eaf285986ce79df59818283 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:34:32 +0000 Subject: [PATCH 7/9] Don't synchronize the install media into the guest packer keeps the ISO in `packer_cache`, inside the workspace the action copies into the guest: half a gigabyte with no use there. Delete it once the build is done. The guest now reports the size it received, so the next time this grows there is something to look at. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5dc1287..5400b0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -135,6 +135,11 @@ jobs: '${{ matrix.architecture.name }}' \ $extra_args + # packer keeps the ISO in the workspace, which the action synchronizes + # into the guest: half a gigabyte with no use there. + - name: Remove the install media + run: rm -rf packer_cache + # When the install goes wrong, packer only reports that it gave up # waiting for SSH. The guest console says what actually happened. # @@ -216,6 +221,9 @@ jobs: printf 'whoami: '; whoami printf 'hostname: '; hostname echo 'ls -lah:'; ls -lah + # What the synchronization actually carried in. The workspace holds + # the built image as well as the checkout, so this is worth seeing. + printf 'du -sh .: '; du -sh . echo 'env | sort:'; env | sort [ "`uname -s`" = 'NetBSD' ] [ "`uname -r`" = '${{ matrix.version }}' ] From 1f9db8014bb4ff322ce894b64794f229c78e9aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 05:54:19 +0000 Subject: [PATCH 8/9] Test the image against the action's bundle branch The bundle is unreadable to action@master, which builds a .qcow2 URL, so the image test cannot pass until the action side ships. Point it at the branch that reads bundles, so this repository's CI is green on its own. Revert to master once action#162 has merged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5400b0e..bc265a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -198,9 +198,13 @@ jobs: date -u +%s > host_epoch.txt # qemu architectures are booted by cross-platform-actions/action. + # + # Pinned to the branch that can read the bundle rather than master, which + # still builds a .qcow2 URL. Change back to master once action#162 has + # merged. - name: Test Image if: matrix.architecture.name != 'vax' - uses: cross-platform-actions/action@master + uses: cross-platform-actions/action@claude/netbsd-11-microvm-kernel-wsrfrg with: operating_system: netbsd architecture: ${{ matrix.architecture.name }} From 27ab7eff0512dcc968bce2e25809418d8d160cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 06:21:49 +0000 Subject: [PATCH 9/9] Don't require a CA store the 9.x images don't have The image test asserts an HTTPS fetch to prove the static network configuration works. certctl(8), which populates the system CA store, arrived in NetBSD 10, so on 9.2, 9.3 and 9.4 that fetch fails on trust rather than on connectivity: curl: (60) unable to get local issuer certificate Attempt HTTPS everywhere still, and fall back to plain HTTP only where the release explains it. A broken CA store on 10 or later keeps failing the test rather than quietly downgrading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9LHkNMNNfDf2Fa1ieGT8g --- .github/workflows/build.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc265a8..81d9ee5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -281,7 +281,21 @@ jobs: ifconfig -l netstat -rn -f inet | grep -q '^default' grep -q '^nameserver' /etc/resolv.conf - curl -fsS -o /dev/null https://github.com + + # HTTPS is attempted everywhere, but only 10 and later can pass it: + # certctl(8), which populates the system CA store, arrived in + # NetBSD 10, so on 9.x this fails on trust rather than on + # connectivity, which is not what is being asserted here. Tolerate + # that only there -- the uname test keeps a broken CA store on a + # release that has one from passing quietly. The clock is checked + # against the host's below in either case. + if curl -fsS -o /dev/null https://github.com; then + echo 'https: ok' + else + echo 'https: no CA store, asserting plain http instead' + [ "$(uname -r | cut -d. -f1)" -lt 10 ] + curl -fsS -o /dev/null http://github.com + fi # Duplicate address detection is off, so the address is usable as # soon as it is configured and rc doesn't wait for it. Assert the