From 32a63411d34b78b712a144e6a70c1a9c5ba574a3 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Thu, 8 Oct 2026 12:58:00 +0200 Subject: [PATCH] feat: say whether a host is still in its first boot first_boot_pending() is true while /firstboot exists on FreeBSD. A FreeBSD cloud image upgrades its base system on its first boot, starts sshd only after that and restarts right away, and /etc/rc removes /firstboot just before that restart. netOrk waits for this before it sets up a new VM (NetOrk/netork#795). OpenBSD has no such marker to ask yet, so it returns False there. --- CHANGELOG.md | 3 +++ README.md | 6 ++++++ napalm_bsd/base.py | 15 +++++++++++++++ napalm_bsd/freebsd.py | 2 ++ tests/test_drivers.py | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae94610..a864994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,5 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 sysDescr; `get_snmp_config` reports a running agent's community and port. - `get_host_status` (napalm-device-types' `HostStatusMixin`): on FreeBSD, "reboot required" when the installed kernel differs from the running one. +- `first_boot_pending()`: whether a FreeBSD host is still in its first boot + (`/firstboot`), so netOrk can wait before setting up a new VM + (NetOrk/netork#795). - Parsers tested on output recorded from FreeBSD 15.1 (hand-installed and cloud image) and OpenBSD 7.9 (NetOrk/netork#793). diff --git a/README.md b/README.md index cb83e5c..42ff87f 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ over SSH, built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-dev | `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … `; `rcctl ls on` + `rcctl check` / `rcctl ` | | `run_device_action("fix_snmp")`, `get_snmp_config` | ✓ | ✓ | net-snmp from packages: `/usr/local/etc/snmp/snmpd.conf` + `service snmpd`; `/etc/snmp/snmpd.conf` + `rcctl … netsnmpd` | | `get_host_status` | ✓ | unknown | napalm-device-types' host status: on FreeBSD `freebsd-version -k` vs `-r` (4.1+) | +| `first_boot_pending` | ✓ | always `False` | whether `/firstboot` still exists | `get_listening_sockets` has the shape of napalm-device-types' `ListeningSocketsMixin` (whose `ss`/cgroup reading is Linux's) and its rule: @@ -68,6 +69,11 @@ database pkg cannot read (only pkg's "not installed" means no packages). (`freebsd-version -k`) differs from the running one (`-r`) as needing a reboot. OpenBSD has no such reading yet, so it stays unknown there. +**First boot.** A FreeBSD cloud image upgrades its base system on its first boot, +starts sshd only after that and restarts right away. `/etc/rc` removes `/firstboot` +just before the restart, so `first_boot_pending()` is true until then; netOrk waits +for it before it sets up a new VM. OpenBSD has no such marker to ask yet. + **Services** are the enabled ones. FreeBSD reads their status as root, as root-only pidfiles hide a daemon from anyone else, and without root when sudo refuses; OpenBSD's `rcctl check` needs no root. Actions run as root. diff --git a/napalm_bsd/base.py b/napalm_bsd/base.py index 9f92cad..810d7b0 100644 --- a/napalm_bsd/base.py +++ b/napalm_bsd/base.py @@ -188,6 +188,21 @@ class BsdDriver(HostStatusMixin, OSDriver): and the readers below treat empty output as "nothing there".""" return self.run_command(command, privileged=privileged, timeout=timeout).stdout.strip() + #: A file that exists until the host's first boot is over; "" when there is none to ask. + FIRST_BOOT_MARKER = "" + + def first_boot_pending(self) -> bool: + """Whether the host is still in its first boot. + + A FreeBSD cloud image upgrades its base system on the first boot, starts + sshd only after that and restarts right away; ``/etc/rc`` removes + ``/firstboot`` just before the restart. netOrk waits for this before it + sets up a new VM (NetOrk/netork#795). + """ + if not self.FIRST_BOOT_MARKER: + return False + return self.run_command(f"test -e {self.FIRST_BOOT_MARKER}").exit_code == 0 + def _run_host_status_command(self, command: str) -> str: """The transport for ``HostStatusMixin.get_host_status``: read-only, no root. diff --git a/napalm_bsd/freebsd.py b/napalm_bsd/freebsd.py index 4e882cf..17ad9d3 100644 --- a/napalm_bsd/freebsd.py +++ b/napalm_bsd/freebsd.py @@ -31,6 +31,8 @@ class FreeBSDDriver(BsdDriver): ROUTES_COMMAND = "netstat -rnW" # sockstat names the process of every socket it can see; root sees them all. LISTENING_COMMAND = "sockstat -46lq -P tcp,udp" + # /etc/rc removes it at the end of the first boot. + FIRST_BOOT_MARKER = "/firstboot" def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]: return parse.sockstat(output) diff --git a/tests/test_drivers.py b/tests/test_drivers.py index 776ba42..91e0359 100644 --- a/tests/test_drivers.py +++ b/tests/test_drivers.py @@ -610,3 +610,26 @@ class TestAFailedReadRaises: driver = _channel(OpenBSDDriver, answers) with pytest.raises(RuntimeError, match=failing.split()[0]): driver.get_available_updates() + + +class TestFirstBoot: + """Whether the host is still in its first boot (NetOrk/netork#795). + + A FreeBSD cloud image upgrades its base system on the first boot, starts + sshd only then and restarts right after; /etc/rc removes /firstboot just + before that restart. netOrk waits for it before setting up a new VM. + """ + + def test_freebsd_in_its_first_boot(self): + driver = _channel(FreeBSDDriver, {"test -e /firstboot": ("", 0)}) + assert driver.first_boot_pending() is True + assert driver.calls == [("test -e /firstboot", False)] + + def test_freebsd_after_its_first_boot(self): + driver = _channel(FreeBSDDriver, {"test -e /firstboot": ("", 1)}) + assert driver.first_boot_pending() is False + + def test_openbsd_has_no_marker_to_ask(self): + driver = _channel(OpenBSDDriver, {}) + assert driver.first_boot_pending() is False + assert driver.calls == [] -- 2.54.0