Compare commits

...

5 Commits

16 changed files with 2806 additions and 55 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/TODO.md

225
docs/GUIX_DIFFERENCES.md Normal file
View File

@@ -0,0 +1,225 @@
# Fruix differences for Guix sysadmins
Date: 2026-04-04
This document is aimed at operators who already know Guix well and want a quick map of where Fruix behaves similarly and where it intentionally differs.
The short version is:
- Fruix is strongly **Guix-inspired**
- it tries to preserve the important semantic properties
- but it does **not** copy Guix mechanically where FreeBSD or Fruix-specific concerns make a different representation clearer
## Big picture
Fruix keeps the core Guix ideas you would expect:
- declarative inputs
- content-addressed store paths
- immutable build outputs
- rollback-friendly deployment identity
- provenance tied to the deployed closure rather than mutable in-place state
But Fruix differs in at least four major ways today:
1. it targets **FreeBSD**, not GNU/Linux
2. its system frontend is currently smaller:
- `fruix system build|rootfs|image|install|installer|installer-iso`
3. it treats **FreeBSD source provenance** as an explicit deployment concern
4. its installed-system generation model is still earlier-stage than Guix's mature system-generation workflow
## Similarities to Guix
If you know Guix System, these Fruix properties should feel familiar.
### Immutable deployment identity
A deployed Fruix system is identified primarily by its closure path in `/frx/store`, not by mutable files under `/etc` or `/usr/local`.
### `/run/current-system`
Fruix keeps the Guix-like runtime convention:
- `/run/current-system`
That path remains the active runtime boundary used by activation and service wiring.
### Rollback-friendly semantics
Fruix avoids in-place mutation of an older deployed closure.
The validated rollback story today is:
- keep the earlier declaration
- rebuild or rematerialize it
- boot or redeploy that earlier closure again
That is Guix-like in spirit even though Fruix does not yet expose the same installed-system rollback command surface.
### Generation-style metadata and roots
Fruix now records explicit installed-system generation state under:
- `/var/lib/fruix/system`
and explicit retention roots under:
- `/frx/var/fruix/gcroots`
This preserves the basic Guix idea that deployment state and reachability should be represented explicitly rather than inferred from whatever happens to be on disk.
## Important differences from Guix
## 1. Fruix does not mirror Guix's on-disk generation layout 1:1
This is intentional.
Guix heavily reuses its profile-generation model and represents a lot of meaning through symlink structure such as profile links and system generation links.
Fruix keeps the **semantics** but uses a more explicit metadata-oriented layout for installed systems.
Current Fruix layout:
```text
/var/lib/fruix/system/
current -> generations/1
current-generation
generations/
1/
closure -> /frx/store/...-fruix-system-...
metadata.scm
provenance.scm
install.scm
```
Why Fruix does this:
- it makes deployment state easier to inspect directly
- it gives FreeBSD-specific install and provenance details a clearer home
- it keeps room for richer operator tooling later without losing the Guix properties
So the rule of thumb is:
- **same semantics as Guix where practical**
- **not necessarily the same directory names or symlink vocabulary**
## 2. Fruix currently keeps `/run/current-system` simple
Even though Fruix now records explicit generation metadata under `/var/lib/fruix/system`, it still keeps:
- `/run/current-system -> /frx/store/...`
rather than making `/run/current-system` point through a generation directory first.
This was chosen to preserve compatibility with the already-validated activation and runtime model while adding explicit generation metadata separately.
## 3. Fruix treats source provenance more explicitly
Guix sysadmins are used to derivation/store provenance. Fruix adds an extra emphasis because its current FreeBSD story depends on explicit source selection and materialization.
Fruix routinely records:
- declared FreeBSD source object metadata
- materialized source store paths
- source materialization metadata files
- closure-level store layout metadata
- install metadata on the target system
This is more prominent in Fruix than most Guix system docs because FreeBSD base/source identity has been an active design concern for this project.
## 4. Fruix has installer-media workflows as first-class system actions
Guix has installation media and image workflows, but Fruix's current system frontend makes these especially explicit because they are still part of the active architectural bring-up story.
Current Fruix actions include:
- `fruix system install`
- `fruix system installer`
- `fruix system installer-iso`
That is a little more deployment-medium-oriented than the mental model many Guix users start with.
## 5. Device naming is more environment-sensitive than Guix users may expect
Because Fruix is on FreeBSD, the install target device naming is not the same as on Linux.
Validated examples:
- installer disk-image path under QEMU:
- `/dev/vtbd1`
- installer ISO path under QEMU:
- `/dev/vtbd0`
- installer ISO path under XCP-ng:
- `/dev/ada0`
So compared with Guix-on-Linux intuition, Fruix operators should be more explicit about target-device selection during installation and installer-media validation.
## 6. Fruix does not yet have Guix-equivalent installed-system generation commands
This is the biggest current operational gap.
Fruix does **not** yet provide a mature equivalent of the familiar Guix System operator flow around in-place generation switching and rollback commands.
Today, Fruix rollback is mostly:
- declaration-driven
- rebuild/redeploy based
rather than:
- switch current system generation in place through a dedicated command
So if you come from Guix, assume that Fruix currently has:
- strong closure/store semantics
- explicit install artifacts
- explicit generation metadata roots
- but a less mature installed-system generation UX
## Where Fruix is intentionally trying to improve on Guix's representation
Fruix is not trying to improve on Guix's core semantics. Guix already got those right.
Where Fruix is intentionally experimenting is mostly the **representation layer**:
- make generation state more legible to operators
- make provenance more visible without needing to reconstruct it mentally from symlink layout alone
- separate:
- runtime entry point (`/run/current-system`)
- installed deployment state (`/var/lib/fruix/system`)
- retention roots (`/frx/var/fruix/gcroots`)
That is why Fruix currently prefers a small per-generation metadata directory instead of only a bare generation link.
## Practical operator advice for Guix users
If you are already comfortable with Guix, the safest Fruix mental model today is:
1. think in terms of immutable closures and declarations first
2. use `fruix system build` as the canonical starting point
3. treat image/install/installer/installer-iso as deployment materializers built from the same declaration
4. identify a deployment by:
- closure path
- source provenance metadata
- install metadata
5. think of rollback today as:
- “redeploy the earlier declaration again”
rather than:
- “switch to an already-managed previous generation in place”
## Status summary
Today Fruix is closest to Guix in:
- store and closure semantics
- declarative deployment identity
- rollback-friendly immutability
- `/run/current-system` runtime model
It differs most from Guix in:
- FreeBSD platform details
- source-provenance emphasis
- installer-medium-oriented workflows
- generation-layout representation
- and the still-maturing installed-system generation command surface

View File

@@ -24,6 +24,12 @@ Fruix currently has:
- `fruix system install`
- a bootable Fruix-managed installer environment:
- `fruix system installer`
- a bootable Fruix-managed installer ISO:
- `fruix system installer-iso`
- an explicit installed-system generation layout under:
- `/var/lib/fruix/system`
- explicit installed-system retention roots under:
- `/frx/var/fruix/gcroots`
Validated boot modes still are:
@@ -36,45 +42,38 @@ The validated Phase 18 installation work currently uses:
## Latest completed achievement
### 2026-04-04 — Phase 18.2 completed
### 2026-04-04 — Phase 19.2 completed
Fruix now boots a minimal installer environment and installs a target system from inside it.
Fruix now records an explicit installed-system generation layout and retention-root model instead of relying mainly on harness knowledge.
Highlights:
- added in `modules/fruix/system/freebsd.scm`:
- `installer-operating-system`
- `operating-system-installer-image-spec`
- `materialize-installer-image`
- added CLI support in `scripts/fruix.scm`:
- `fruix system installer`
- `--install-target-device DEVICE`
- the installer image now carries:
- its own installer closure
- the selected target closure
- the target store closure
- a staged target rootfs payload
- in-guest installer state/log/scripts
- validated workflow:
- boot installer image in QEMU/UEFI/TCG
- reach installer over SSH
- install target system onto second disk from inside the guest
- boot the installed target successfully
- added explicit installed-system generation layout under:
- `/var/lib/fruix/system`
- added explicit installed-system retention roots under:
- `/frx/var/fruix/gcroots`
- installed targets now record a first-generation deployment directory containing:
- `closure`
- `metadata.scm`
- `provenance.scm`
- `install.scm`
- `/run/current-system` remains the runtime boundary and still points directly at the active closure path
- added Guix-oriented operator notes in:
- `docs/GUIX_DIFFERENCES.md`
- updated deployment workflow documentation to reflect the new explicit generation model
Validation:
- `PASS phase18-installer-environment`
- regression re-checks:
- `PASS phase18-system-install`
- `PASS phase17-source-revisions-qemu`
- `PASS phase19-generation-layout-qemu`
- regression re-check:
- `PASS phase18-installer-iso`
Report:
Reports:
- `docs/reports/phase18-installer-environment-freebsd.md`
Commit:
- `1d00907``Add Fruix bootable installer environment`
- `docs/system-deployment-workflow.md`
- `docs/GUIX_DIFFERENCES.md`
- `docs/reports/phase19-deployment-workflow-freebsd.md`
- `docs/reports/phase19-generation-layout-freebsd.md`
## Recent major milestones
@@ -100,6 +99,6 @@ Commit:
Per `docs/PLAN_4.md`, the next planned step is:
- **Phase 18.3** — produce a bootable UEFI installer ISO
- **Phase 19.3** — validate installed-system rollback through the intended operator-facing workflow
That should build on the now-validated installer environment rather than replacing it.
Phase 19.2 is now complete: Fruix has an explicit installed-system generation layout and retention-root model on FreeBSD.

View File

@@ -0,0 +1,277 @@
# Phase 18.3: bootable Fruix installer ISO on FreeBSD
Date: 2026-04-04
## Goal
Phase 18.3 extends the Phase 18.2 installer-environment work from a disk-image-style installer into a UEFI-bootable ISO artifact.
The intended first ISO is deliberately narrow:
- UEFI only
- serial-console-friendly
- non-interactive install flow reused from Phase 18.1/18.2
- target disk installation still performed by the same Fruix-managed in-guest installer logic
## Implementation
### New API
Added in `modules/fruix/system/freebsd.scm`:
- `operating-system-installer-iso-spec`
- `materialize-installer-iso`
The system module split done immediately before this phase was also exercised during this work.
### New CLI action
Added in `scripts/fruix.scm`:
- `fruix system installer-iso`
This action emits metadata for:
- ISO store path
- ISO image path
- EFI boot image path
- installer root image path
- installer and target closure paths
- installer state/log paths
- declared/materialized FreeBSD source metadata
- store closure counts
### ISO boot model
The ISO does not try to run the Fruix installer directly from a read-only cd9660 root.
Instead it uses a small UEFI El Torito boot image plus an in-memory installer root image:
1. a small FAT EFI boot image contains `EFI/BOOT/BOOTX64.EFI`
2. the ISO root contains real boot assets under `/boot`
3. the ISO root also contains `/boot/root.img`
4. `loader.conf` on the ISO is augmented with:
- `mdroot_load="YES"`
- `mdroot_type="mfs_root"`
- `mdroot_name="/boot/root.img"`
- `vfs.root.mountfrom="ufs:/dev/md0"`
- `vfs.root.mountfrom.options="rw"`
A practical loader detail surfaced during validation:
- setting `rootdev` or `currdev` to `md0:` in the ISO loader path is wrong for this loader configuration and caused an early EFI-loader crash before kernel handoff
- the reliable ISO path is to let loader keep its current device on the CD media, preload `/boot/root.img`, and pass only `vfs.root.mountfrom=ufs:/dev/md0`
This preserves the existing Fruix installer environment semantics while avoiding the need to make the whole installer operate directly from a read-only ISO root.
### Installer root image contents
`materialize-installer-iso` stages the same installer payload model already validated in Phase 18.2:
- installer closure
- target closure
- target runtime store closure needed for installation/boot
- staged target rootfs under `/var/lib/fruix/installer/target-rootfs`
- installer plan and state files under `/var/lib/fruix/installer`
- installer helper scripts:
- `/usr/local/libexec/fruix-installer-run`
- `/usr/local/etc/rc.d/fruix-installer`
The ISO root image is then built as a UFS image and embedded as `/boot/root.img`.
### Split-regression fixes found during this work
While exercising the refactored split modules, two issues surfaced and were fixed:
1. `string-hash` name-clash warnings
- the old helper name collided with Guile/SRFI bindings
- it was renamed to `sha256-string`
2. missing `prefix-materializer-version`
- this constant was accidentally omitted when `modules/fruix/system/freebsd.scm` was split
- the missing definition was restored in `modules/fruix/system/freebsd/build.scm`
## Validation
### Completed smoke validation
A host-side smoke build was completed successfully for the new ISO builder using a host-staged operating-system definition:
- command pattern:
- `fruix system installer-iso ...`
- result:
- successful ISO materialization in a temporary store
- artifact checks performed:
- `etdump` reports an EFI El Torito boot entry
- the ISO contains:
- `boot/kernel/kernel`
- `boot/kernel/linker.hints`
- `boot/loader.conf`
- `boot/loader.efi`
- `boot/root.img`
- `boot/loader.conf` inside the ISO contains the expected `mdroot_*` and `vfs.root.mountfrom` entries
Example smoke-build metadata:
```text
action=installer-iso
iso_volume_label=FRUIX_INSTALLER
iso_store_path=/tmp/...-fruix-installer-iso-fruix-freebsd-installer
iso_image=/tmp/...-fruix-installer-iso-fruix-freebsd-installer/installer.iso
boot_efi_image=/tmp/...-fruix-installer-iso-fruix-freebsd-installer/efiboot.img
root_image=/tmp/...-fruix-installer-iso-fruix-freebsd-installer/root.img
installer_closure_path=/tmp/...-fruix-system-fruix-freebsd-installer
target_closure_path=/tmp/...-fruix-system-fruix-freebsd
```
### End-to-end harness validation
Added:
- `tests/system/run-phase18-installer-iso.sh`
This harness validates the full Phase 18.3 flow:
1. build installer ISO
2. boot it under QEMU/UEFI/TCG
3. install onto a target disk from inside the booted ISO environment
4. boot the installed target
Passing validation:
- `PASS phase18-installer-iso`
Validated result summary:
```text
installer_iso_store_path=/frx/store/...-fruix-installer-iso-fruix-freebsd-installer
installer_iso_image=/frx/store/...-fruix-installer-iso-fruix-freebsd-installer/installer.iso
installer_boot_efi_image=/frx/store/...-fruix-installer-iso-fruix-freebsd-installer/efiboot.img
installer_root_image=/frx/store/...-fruix-installer-iso-fruix-freebsd-installer/root.img
install_target_device=/dev/vtbd0
freebsd_source_kind=git
freebsd_source_ref=stable/15
freebsd_source_commit=332708a606f6bf0841c1d4a74c0d067f5640fe89
materialized_source_store=/frx/store/459499e0eb29f4c73ad455060dd2502d21fb56f205c0a676831cf723b3a0c378-freebsd-source-stable15-installer-iso-target-source
installer_state=done
installer_sshd_status=running
target_esp_fstype=msdosfs
target_root_fstype=ufs
target_shepherd_status=running
target_sshd_status=running
installer_iso_boot=ok
installer_iso_install=ok
installed_target_boot=ok
```
Notable QEMU-specific ISO validation detail:
- unlike the disk-image-style installer environment from Phase 18.2, the ISO boots from `cd0`, so the target virtio disk appears as:
- `/dev/vtbd0`
- the earlier installer-environment default:
- `/dev/vtbd1`
remains correct for the disk-image installer, but not for the ISO path
The harness verified all of the following:
1. `fruix system installer-iso` produces a bootable ISO artifact in `/frx/store`
2. the ISO boots successfully under QEMU/UEFI/TCG
3. the booted installer ISO environment becomes reachable over SSH
4. `/run/current-system` inside the installer ISO points at the installer closure
5. the installer rc.d job reaches:
- `state=done`
6. the installer log records:
- `fruix-installer:done`
7. the installed target disk contains:
- GPT partitioning
- EFI filesystem: `msdosfs`
- root filesystem: `ufs`
- `EFI/BOOT/BOOTX64.EFI`
- `/var/lib/fruix/install.scm`
8. the installed target then boots successfully as its own Fruix system under QEMU/UEFI/TCG
9. after target boot:
- `/run/current-system` points at the target closure
- shepherd is running
- `sshd` is running
- activation completed successfully
### Real XCP-ng validation
Added:
- `tests/system/run-phase18-installer-iso-xcpng.sh`
This harness validates the same installer-iso workflow on the approved real XCP-ng path:
- VM: `90490f2e-e8fc-4b7a-388e-5c26f0157289`
- ISO SR: `537a6219-8452-7cb5-8d56-5eed6910c7a2`
- target VDIs:
- `0f1f90d3-48ca-4fa2-91d8-fc6339b95743`
- `7061d761-3639-4bec-87f7-2ba1af924eaa`
Because the current `xo-cli disk.import @=/path/to.iso` path returned an HTTP 500 error in this environment, the harness imports the ISO into the ISO SR via a temporary local HTTP URL, then inserts the resulting ISO VDI into the VM's CD drive.
Passing validation:
- `PASS phase18-installer-iso-xcpng`
Validated result summary:
```text
vm_id=90490f2e-e8fc-4b7a-388e-5c26f0157289
iso_id=<temporary-imported-iso-vdi>
guest_ip=192.168.213.62
installer_state=done
installer_target_device=/dev/ada0
kern_disks=cd0 ada1 ada0
installer_run_current_system=/frx/store/16969e825dbb65b5c27180030d4a7d98821893460fb3dccdc863ff6156ed61e0-fruix-system-fruix-freebsd-installer
installer_sshd_status=running
target_run_current_system=/frx/store/a98d3af6a1afbc4a927d47cea6458d5a70747b051ed994e5d9ff1ae79c4f2b42-fruix-system-fruix-freebsd
target_sshd_status=running
target_shepherd_status=running
```
Important XCP-ng-specific details:
- the installer ISO still boots from:
- `cd0`
- on this Xen HVM path, the primary target disk is exposed through Xen block front as `xbd0` and appears to FreeBSD as:
- `/dev/ada0`
- therefore the XCP-ng installer-iso path must target:
- `/dev/ada0`
rather than QEMU's:
- `/dev/vtbd0`
- the visible EFI console can appear to stop at:
- `console vidconsole is unavailable`
but boot still continues and the installer becomes reachable over SSH; that message was not the actual failure mode on XCP-ng
The harness verified all of the following on the real VM path:
1. `fruix system installer-iso` builds a bootable ISO with `--install-target-device /dev/ada0`
2. the ISO can be imported into the operator-approved ISO SR and attached to the approved VM
3. the VM boots the Fruix installer ISO successfully under UEFI
4. the installer environment becomes reachable over SSH
5. inside the installer guest:
- `kern.disks` includes `cd0` and `ada0`
- `/run/current-system` points at the installer closure
- the installer reaches `state=done`
6. the installed target on `ada0` is partitioned and formatted correctly
7. after ejecting the ISO and rebooting, the installed target boots successfully on the same XCP-ng VM
8. after target boot:
- `/run/current-system` points at the target closure
- shepherd is running
- `sshd` is running
- activation completed successfully
- `/var/lib/fruix/install.scm` still records the materialized source store provenance
## Result
Phase 18.3 is complete.
Fruix now has a validated bootable UEFI installer ISO on FreeBSD that can:
- boot into a Fruix-managed installer environment from ISO media
- perform the non-interactive installation flow onto a target disk
- boot the installed target successfully
- and do so on both:
- local `QEMU/UEFI/TCG`
- the approved real `XCP-ng` VM path

View File

@@ -0,0 +1,143 @@
# Phase 19.1: canonical Fruix deployment workflow on FreeBSD
Date: 2026-04-04
## Goal
Phase 19.1 is about turning Fruix's already-validated closure/image/install behavior into a clear operator-facing deployment story.
The verification target here is documentation clarity rather than a new low-level boot primitive.
The repo needed a single coherent explanation of how Fruix expects operators to:
- build a system closure
- materialize a rootfs or image
- install directly to an image or block device
- use the installer image and installer ISO paths
- roll forward to a candidate declaration
- roll back to an earlier declaration
## Result
Phase 19.1 is complete.
The repository now documents a first-class Fruix deployment workflow in:
- `docs/system-deployment-workflow.md`
That document defines the current canonical command surface and explains how the already-existing validated paths fit together operationally.
## What was documented
### Canonical frontend
The documented user-facing frontend is now explicitly:
- `./bin/fruix system ...`
This includes the currently supported deployment-oriented actions:
- `build`
- `rootfs`
- `image`
- `install`
- `installer`
- `installer-iso`
### Canonical deployment model
The workflow document now defines Fruix's current deployment model as:
1. declare a system in Scheme
2. build the system closure in `/frx/store`
3. materialize the artifact appropriate to the target environment
4. boot or install that artifact
5. treat the resulting closure path and emitted provenance metadata as the deployment identity
### Roll-forward and rollback semantics
The document makes explicit an important current design point:
- Fruix rollback is already real at the declaration/closure/deployment layer
- but it is not yet a first-class installed-system generation switch operation
So the documented rollback workflow today is:
- retain the earlier declaration
- rebuild or rematerialize it
- redeploy or reboot that earlier closure again
That matches what Fruix has already validated in earlier phases.
### Platform-specific installer target-device detail
The workflow document also records the now-important target-device distinctions between validated environments:
- installer disk-image path under QEMU:
- `/dev/vtbd1`
- installer ISO path under QEMU:
- `/dev/vtbd0`
- installer ISO path under XCP-ng:
- `/dev/ada0`
That makes the deployment story less harness-specific and more operator-explicit.
## Why this satisfies Phase 19.1
Before this phase, Fruix already had the machinery for:
- building declarative system closures
- generating bootable images
- performing direct non-interactive installation
- booting a Fruix installer environment
- booting and installing from a Fruix installer ISO
- rollback-friendly redeploy of earlier declarations
What was missing was a repo-level explanation that unified those into a single operator workflow.
The new document closes that gap by connecting:
- Phase 10 command-surface work
- Phase 15 redeploy/rollback validation
- Phase 18 install and installer-media validation
- and the recent QEMU + XCP-ng installer ISO validation
## Current boundaries now made explicit
The documentation intentionally records what Fruix has **not** solved yet:
- installed-system generation links
- explicit rollback targets and generation metadata
- a first-class `switch` or `reconfigure` command
- installed-system rollback as an in-place operator workflow
- GC-root management for installed systems
Those are left for later Phase 19 steps rather than being blurred into the current deployment story.
## References to existing validation
The documented workflow rests on already-passing validation paths, including:
- `PASS phase18-system-install`
- `PASS phase18-installer-environment`
- `PASS phase18-installer-iso`
- `PASS phase18-installer-iso-xcpng`
- `PASS phase15-base-rollback-qemu`
- `PASS phase15-base-rollback-xcpng`
## Conclusion
Phase 19.1 is now complete.
Fruix has a documented canonical deployment workflow for FreeBSD covering:
- build
- image generation
- direct install
- installer-media install
- roll-forward
- rollback by redeploying an earlier declaration
The next step is Phase 19.2:
- model installed-system generations, rollback targets, and deployment roots more explicitly.

View File

@@ -0,0 +1,192 @@
# Phase 19.2: explicit installed-system generation layout on FreeBSD
Date: 2026-04-04
## Goal
Phase 19.2 is about making Fruix's installed-system generation model more explicit.
The target here is not yet a full Guix-equivalent in-place `switch-generation` workflow.
The immediate goal is to stop relying mainly on harness knowledge and implicit symlink expectations by recording installed deployment state more explicitly on disk.
## Decision
Fruix now follows this design direction:
- keep Guix-like **semantics**
- do not mirror Guix's installed-system/profile layout **mechanically 1:1**
What Fruix preserves from Guix:
- immutable closure identity
- rollback-friendly deployment semantics
- explicit current deployment pointer
- GC-root-style retention links
- `/run/current-system` as the active runtime boundary
What Fruix intentionally changes:
- installed-system generation state is represented as a small metadata-bearing directory
- the generation model is recorded under a Fruix-native path
- deployment metadata and provenance are easier to inspect directly without reconstructing intent from symlink layout alone
## Implemented layout
Installed systems now record an explicit generation root under:
- `/var/lib/fruix/system`
Current validated initial layout:
```text
/var/lib/fruix/system/
current -> generations/1
current-generation
generations/
1/
closure -> /frx/store/...-fruix-system-...
metadata.scm
provenance.scm
install.scm
```
Installed systems now also create explicit retention roots under:
- `/frx/var/fruix/gcroots`
Current validated initial layout:
```text
/frx/var/fruix/gcroots/
current-system -> /frx/store/...-fruix-system-...
system-1 -> /frx/store/...-fruix-system-...
```
Important compatibility point:
- `/run/current-system` still points directly at the active closure in `/frx/store`
That means the new explicit generation model strengthens deployment metadata without changing the already-validated runtime contract used by activation, rc.d integration, and service startup.
## Code changes
### `modules/fruix/system/freebsd/media.scm`
Added explicit generation-layout helpers:
- generation metadata object writer
- generation provenance object writer
- generation layout population for staged rootfs trees
The system rootfs staging path now creates explicit generation state during rootfs population.
That affects:
- direct rootfs materialization
- direct image materialization
- direct installation targets
- target rootfs payloads staged inside installer images
- target rootfs payloads staged inside installer ISOs
The direct install path now also refreshes the generation layout after writing:
- `/var/lib/fruix/install.scm`
so the generation directory carries the same install metadata.
### Installer runtime path
The generated installer runtime script now also copies install metadata into:
- `/var/lib/fruix/system/generations/1/install.scm`
on the installed target.
This keeps direct-install and installer-mediated installs aligned.
## New validation harness
Added:
- `tests/system/run-phase19-generation-layout-qemu.sh`
This harness builds on the already-passing direct install validation from Phase 18.1 and then verifies the new explicit generation layout on the installed target image.
Passing validation:
- `PASS phase19-generation-layout-qemu`
Validated result summary:
```text
closure_path=/frx/store/882fb4a9fbb05f08e77de29f70ca50f3c01dd29141e72688d32770a3172747e7-fruix-system-fruix-freebsd
current_generation=1
current_link=generations/1
generation_closure=/frx/store/882fb4a9fbb05f08e77de29f70ca50f3c01dd29141e72688d32770a3172747e7-fruix-system-fruix-freebsd
gcroot_current=/frx/store/882fb4a9fbb05f08e77de29f70ca50f3c01dd29141e72688d32770a3172747e7-fruix-system-fruix-freebsd
gcroot_generation=/frx/store/882fb4a9fbb05f08e77de29f70ca50f3c01dd29141e72688d32770a3172747e7-fruix-system-fruix-freebsd
run_current_system_target=/frx/store/882fb4a9fbb05f08e77de29f70ca50f3c01dd29141e72688d32770a3172747e7-fruix-system-fruix-freebsd
generation_layout=explicit
generation_layout_validation=ok
```
The harness verified all of the following:
1. the installed target contains:
- `/var/lib/fruix/system`
2. the current generation pointer exists and resolves to:
- `generations/1`
3. the generation directory contains:
- `closure`
- `metadata.scm`
- `provenance.scm`
- `install.scm`
4. the generation closure link points at the installed closure in `/frx/store`
5. the generation metadata records:
- closure path
- install metadata path
6. the generation install metadata records:
- closure path
- materialized source provenance
7. explicit retention roots exist under:
- `/frx/var/fruix/gcroots/current-system`
- `/frx/var/fruix/gcroots/system-1`
8. those GC-root-style links point at the same active closure
9. `/run/current-system` still points directly at the active closure path
10. existing install boot validation remains intact
## Relationship to Guix
Fruix now has an explicit installed-system generation model, but it is still intentionally not a byte-for-byte clone of Guix's on-disk conventions.
The design choice is:
- preserve Guix's deployment semantics
- use a Fruix-native metadata-oriented representation where that improves clarity for operators and debugging
That decision is documented separately in:
- `docs/GUIX_DIFFERENCES.md`
## Current limitations
This phase does **not** yet add:
- multi-generation switching in place
- a `switch`/`reconfigure` command
- an operator-facing rollback command that flips from current to a previous installed generation without redeploy
- explicit `rollback` link management beyond the initial current-generation layout
Those belong to later Phase 19 work.
## Conclusion
Phase 19.2 is complete.
Fruix now has a clearer, explicit installed-system generation and retention-root model on FreeBSD:
- generation metadata is recorded under `/var/lib/fruix/system`
- retention links are recorded under `/frx/var/fruix/gcroots`
- `/run/current-system` remains stable as the runtime boundary
- and the model is documented in Fruix-native terms for Guix-familiar operators

View File

@@ -0,0 +1,400 @@
# Fruix system deployment workflow
Date: 2026-04-04
## Purpose
This document defines the current canonical Fruix workflow for:
- building a declarative system closure
- materializing deployable artifacts
- installing a declarative system onto an image or disk
- booting through installer media
- rolling forward to a candidate system
- rolling back to an earlier declared system
This is the Phase 19.1 operator-facing view of the system model already implemented in earlier phases.
## Core model
A Fruix system workflow starts from a Scheme file that binds an `operating-system` object.
Today, the canonical frontend is:
- `./bin/fruix system ...`
The important output objects are:
- **system closure**
- a content-addressed store item under `/frx/store/*-fruix-system-<host-name>`
- includes boot assets, activation logic, profile tree, metadata, and references
- **rootfs tree**
- a materialized runtime tree for inspection or image staging
- **disk image**
- a bootable GPT/UEFI raw disk image
- **installer image**
- a bootable Fruix installer disk image that installs a selected target system from inside the guest
- **installer ISO**
- a bootable UEFI ISO with an embedded installer mdroot payload
- **install metadata**
- `/var/lib/fruix/install.scm` on installed targets
- records the selected closure path, install spec, and referenced store items including source provenance
The current deployment story is therefore already declaration-driven and content-addressed, even before first-class installed-system generations are modeled more explicitly.
## Canonical command surface
### Build a system closure
```sh
sudo env HOME="$HOME" \
GUILE_AUTO_COMPILE=0 \
GUIX_SOURCE_DIR="$HOME/repos/guix" \
GUILE_BIN="/tmp/guile-freebsd-validate-install/bin/guile" \
GUILE_EXTRA_PREFIX="/tmp/guile-gnutls-freebsd-validate-install" \
SHEPHERD_PREFIX="/tmp/shepherd-freebsd-validate-install" \
./bin/fruix system build path/to/system.scm --system my-operating-system
```
Primary result:
- `closure_path=/frx/store/...-fruix-system-...`
Use this when you want to:
- validate the declarative system composition itself
- inspect provenance/layout metadata
- compare candidate and current closure paths
- drive later rootfs/image/install steps from the same declaration
### Materialize a rootfs tree
```sh
sudo env HOME="$HOME" ... \
./bin/fruix system rootfs path/to/system.scm ./rootfs --system my-operating-system
```
Primary result:
- `rootfs=...`
- `closure_path=/frx/store/...`
Use this when you want to:
- inspect the runtime filesystem layout directly
- stage a tree for debugging
- validate `/run/current-system`-style symlink layout without booting a full image
### Materialize a bootable disk image
```sh
sudo env HOME="$HOME" ... \
./bin/fruix system image path/to/system.scm \
--system my-operating-system \
--root-size 6g
```
Primary result:
- `disk_image=/frx/store/.../disk.img`
Use this when you want to:
- boot the system directly as a VM image
- test a candidate deployment under QEMU or XCP-ng
- validate a roll-forward or rollback candidate by image boot
### Install directly to an image file or block device
```sh
sudo env HOME="$HOME" ... \
./bin/fruix system install path/to/system.scm \
--system my-operating-system \
--target ./installed.img \
--disk-capacity 12g \
--root-size 10g
```
Primary result:
- `target=...`
- `target_kind=raw-file` or `block-device`
- `install_metadata_path=/var/lib/fruix/install.scm`
Use this when you want to:
- produce an installed target image without booting an installer guest
- validate installation mechanics directly
- populate a raw image or a real `/dev/...` target
### Materialize a bootable installer disk image
```sh
sudo env HOME="$HOME" ... \
./bin/fruix system installer path/to/system.scm \
--system my-operating-system \
--install-target-device /dev/vtbd1 \
--root-size 10g
```
Primary result:
- `installer_disk_image=/frx/store/.../disk.img`
Use this when you want to:
- boot a Fruix installer environment as a disk image
- let the in-guest installer partition and install onto a second disk
- validate non-interactive installation from inside a booted Fruix guest
### Materialize a bootable installer ISO
```sh
sudo env HOME="$HOME" ... \
./bin/fruix system installer-iso path/to/system.scm \
--system my-operating-system \
--install-target-device /dev/vtbd0
```
Primary result:
- `iso_image=/frx/store/.../installer.iso`
- `boot_efi_image=/frx/store/.../efiboot.img`
- `root_image=/frx/store/.../root.img`
Use this when you want to:
- boot through UEFI ISO media instead of a writable installer disk image
- install from an ISO-attached Fruix environment
- test the same install model on more realistic VM paths
## Deployment patterns
### 1. Build-first workflow
The default Fruix operator workflow starts by building the closure first:
1. edit the system declaration
2. run `fruix system build`
3. inspect emitted metadata
4. if needed, produce one of:
- `rootfs`
- `image`
- `install`
- `installer`
- `installer-iso`
This keeps the declaration-to-closure boundary explicit.
### 2. VM image deployment workflow
Use this when you want to boot a system directly rather than through an installer.
1. run `fruix system image`
2. boot the image in QEMU or convert/import it for XCP-ng
3. validate:
- `/run/current-system`
- shepherd/sshd state
- activation log
4. keep the closure path from the build metadata as the deployment identity
This is the current canonical direct deployment path for already-built images.
### 3. Direct installation workflow
Use this when you want an installed target image or disk without a booted installer guest.
1. run `fruix system install --target ...`
2. let Fruix partition, format, populate, and install the target
3. boot the installed result
4. validate `/var/lib/fruix/install.scm` and target services
This is the most direct install path.
### 4. Installer-environment workflow
Use this when the install itself should happen from inside a booted Fruix environment.
1. run `fruix system installer`
2. boot the installer disk image
3. let the in-guest installer run onto the selected target device
4. boot the installed target
This is useful when the installer environment itself is part of what needs validation.
### 5. Installer-ISO workflow
Use this when the desired operator artifact is a bootable UEFI ISO.
1. run `fruix system installer-iso`
2. boot the ISO under the target virtualization path
3. let the in-guest installer run onto the selected target device
4. eject the ISO and reboot the installed target
This is now validated on both:
- local `QEMU/UEFI/TCG`
- the approved real `XCP-ng` VM path
## Install-target device conventions
The install target device is not identical across all boot styles.
Current validated defaults are:
- direct installer disk-image path under QEMU:
- `/dev/vtbd1`
- installer ISO path under QEMU:
- `/dev/vtbd0`
- installer ISO path under XCP-ng:
- `/dev/ada0`
Therefore the canonical workflow is:
- always treat `--install-target-device` as an explicit deployment parameter when moving between virtualization environments
Do not assume that a device name validated in one harness is portable to another.
## Installed-system generation layout
Installed Fruix systems now record an explicit first-generation deployment layout under:
- `/var/lib/fruix/system`
Current validated shape:
```text
/var/lib/fruix/system/
current -> generations/1
current-generation
generations/
1/
closure -> /frx/store/...-fruix-system-...
metadata.scm
provenance.scm
install.scm # present on installed targets
```
Installed systems also now create explicit GC-root-style deployment links under:
- `/frx/var/fruix/gcroots`
Current validated shape:
```text
/frx/var/fruix/gcroots/
current-system -> /frx/store/...-fruix-system-...
system-1 -> /frx/store/...-fruix-system-...
```
Important detail:
- `/run/current-system` still points directly at the active closure path in `/frx/store`
- the explicit generation layout therefore adds deployment metadata and retention roots without changing the already-validated runtime contract used by activation, rc.d wiring, and tests
## Roll-forward workflow
The current Fruix roll-forward model is declaration-driven.
Canonical process:
1. keep the current known-good system declaration
2. prepare a candidate declaration
- this may differ by FreeBSD base identity
- source revision
- services
- users/groups
- or other operating-system fields
3. run `fruix system build` for the candidate
4. materialize either:
- `fruix system image`
- `fruix system install`
- `fruix system installer`
- `fruix system installer-iso`
5. boot or install the candidate
6. validate the candidate closure in the booted system
The important property is that the candidate closure appears beside the earlier one in `/frx/store` rather than mutating it in place.
## Rollback workflow
The current canonical rollback workflow is also declaration-driven.
Today, rollback means:
1. retain the earlier declaration that produced the known-good closure
2. rebuild or rematerialize that earlier declaration
3. redeploy or reboot that earlier artifact again
Concretely, the usual rollback choices are:
- rebuild the earlier declaration with `fruix system build` and confirm the old closure path reappears
- boot the earlier declaration again through `fruix system image`
- reinstall the earlier declaration through `fruix system install`, `installer`, or `installer-iso` if the deployment medium itself must change
This rollback story has already been validated at the closure/image/deployment level:
- side-by-side base-version coexistence in `/frx/store`
- roll-forward to a candidate closure
- rollback by rebuilding and booting the earlier declaration again
- validation on both local QEMU and the approved XCP-ng VM path
### Important scope note
This is not yet the same thing as a first-class installed-system generation switch command.
Current rollback is:
- **redeploy the earlier declaration again**
What still remains for later Phase 19 work is making rollback itself operator-driven at the installed-system layer, rather than only declaration/redeploy driven.
Still pending:
- previous-generation tracking beyond the initial explicit generation-1 layout
- an explicit rollback target link distinct from `current`
- an operator-facing installed-system rollback workflow
- generation switching without full redeploy
## Provenance and deployment identity
For any serious deployment or rollback decision, the canonical identity is not merely the host name. It is the emitted metadata:
- `closure_path`
- declared FreeBSD base/source metadata
- materialized source store paths
- install metadata at `/var/lib/fruix/install.scm`
- store item counts and reference lists
Operators should retain metadata from successful candidate and current deployments because Fruix already emits enough data to answer:
- which declaration was built
- which closure booted
- which source snapshot was materialized
- which target device or image was installed
## Current limitations
The deployment workflow is now coherent, but it is not yet the final generation-management story.
Not yet first-class:
- a dedicated `switch` or `reconfigure` command
- an installed-system rollback command that moves between generations in place
- multi-generation retention and previous-generation tracking beyond generation 1
- generation switching policy independent of full redeploy
Those are the next logical steps after the current explicit-generation layout.
## Summary
The current canonical Fruix deployment model is:
- **declare** a system in Scheme
- **build** the closure with `fruix system build`
- **materialize** the artifact appropriate to the deployment target
- **boot or install** that artifact
- **identify deployments by closure path and provenance metadata**
- **roll back by rebuilding/redeploying the earlier declaration**, not by mutating the current closure in place
That is the operator-facing workflow Fruix should document and use while installed-system generation switching remains more limited than Guix's mature in-place system-generation workflow.

View File

@@ -46,10 +46,12 @@
operating-system-install-spec
operating-system-image-spec
operating-system-installer-image-spec
operating-system-installer-iso-spec
installer-operating-system
materialize-operating-system
materialize-rootfs
install-operating-system
materialize-bhyve-image
materialize-installer-image
materialize-installer-iso
default-minimal-operating-system))

View File

@@ -155,7 +155,7 @@
(define (native-build-root common)
(string-append "/var/tmp/fruix-freebsd-native-build-"
(string-hash (object->string common))))
(sha256-string (object->string common))))
(define (native-make-arguments common _build-root)
(append
@@ -254,7 +254,7 @@
(let* ((plan (freebsd-package-install-plan package))
(common (native-build-common-manifest plan))
(build-root (native-build-root common))
(stage-root (string-append build-root "/stage-" (freebsd-package-name package) "-" (string-hash manifest)))
(stage-root (string-append build-root "/stage-" (freebsd-package-name package) "-" (sha256-string manifest)))
(install-log (string-append build-root "/logs/install-" (freebsd-package-name package) ".log"))
(final-stage-root
(case (freebsd-package-build-system package)
@@ -312,7 +312,7 @@
#:sha256 (build-plan-ref plan 'base-source-sha256 #f)))
(define (source-cache-key source)
(string-hash (object->string (freebsd-source-spec source))))
(sha256-string (object->string (freebsd-source-spec source))))
(define (materialize-freebsd-source/cached source store-dir source-cache)
(let* ((key (source-cache-key source))
@@ -360,11 +360,11 @@
input-paths))
(effective-input-paths (filter identity effective-input-paths))
(manifest (package-manifest-string prepared-package effective-input-paths))
(cache-key (string-hash manifest))
(cache-key (sha256-string manifest))
(cached (hash-ref cache cache-key #f)))
(if cached
cached
(let* ((hash (string-hash manifest))
(let* ((hash (sha256-string manifest))
(output-path (string-append store-dir "/" hash "-"
(freebsd-package-name prepared-package)
"-"
@@ -419,6 +419,8 @@
(delete-file-if-exists (string-append output-path "/lib/guile/3.0/site-ccache/shepherd/config.go"))))
#t)
(define prefix-materializer-version "3")
(define (prefix-manifest-string source-path extra-files)
(string-append
"prefix-materializer-version=" prefix-materializer-version "\n"
@@ -452,7 +454,7 @@
(define* (materialize-prefix source-path name version store-dir #:key (extra-files '()))
(let* ((manifest (prefix-manifest-string source-path extra-files))
(hash (string-hash manifest))
(hash (sha256-string manifest))
(output-path (string-append store-dir "/" hash "-" name "-" version)))
(unless (file-exists? output-path)
(mkdir-p output-path)

View File

@@ -10,15 +10,18 @@
#:use-module (ice-9 hash-table)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-13)
#:use-module (rnrs io ports)
#:export (operating-system-install-spec
operating-system-image-spec
operating-system-installer-image-spec
operating-system-installer-iso-spec
installer-operating-system
materialize-operating-system
materialize-rootfs
install-operating-system
materialize-bhyve-image
materialize-installer-image))
materialize-installer-image
materialize-installer-iso))
(define (same-file-contents? a b)
(zero? (system* "cmp" "-s" a b)))
@@ -169,7 +172,7 @@
"\n")
"\nreferences=\n"
(string-join references "\n")))
(hash (string-hash manifest))
(hash (sha256-string manifest))
(closure-path (string-append store-dir "/" hash "-fruix-system-"
(operating-system-host-name os))))
(unless (file-exists? closure-path)
@@ -230,7 +233,76 @@
(mkdir-p (dirname link-name))
(symlink target link-name))
(define (populate-rootfs-from-closure os rootfs closure-path)
(define system-generation-layout-version "1")
(define* (system-generation-metadata-object os closure-path
#:key
(generation-number 1)
install-spec
install-metadata-path)
`((system-generation-version . ,system-generation-layout-version)
(generation-number . ,generation-number)
(host-name . ,(operating-system-host-name os))
(ready-marker . ,(operating-system-ready-marker os))
(init-mode . ,(operating-system-init-mode os))
(closure-path . ,closure-path)
(parameters-file . ,(string-append closure-path "/parameters.scm"))
(freebsd-base-file . ,(string-append closure-path "/metadata/freebsd-base.scm"))
(freebsd-source-file . ,(string-append closure-path "/metadata/freebsd-source.scm"))
(freebsd-source-materializations-file
. ,(string-append closure-path "/metadata/freebsd-source-materializations.scm"))
(host-base-provenance-file . ,(string-append closure-path "/metadata/host-base-provenance.scm"))
(store-layout-file . ,(string-append closure-path "/metadata/store-layout.scm"))
(install-metadata-path . ,install-metadata-path)
(install-spec . ,install-spec)))
(define (system-generation-provenance-object closure-path)
`((closure-path . ,closure-path)
(parameters-file . ,(string-append closure-path "/parameters.scm"))
(freebsd-base-file . ,(string-append closure-path "/metadata/freebsd-base.scm"))
(freebsd-source-file . ,(string-append closure-path "/metadata/freebsd-source.scm"))
(freebsd-source-materializations-file
. ,(string-append closure-path "/metadata/freebsd-source-materializations.scm"))
(host-base-provenance-file . ,(string-append closure-path "/metadata/host-base-provenance.scm"))
(store-layout-file . ,(string-append closure-path "/metadata/store-layout.scm"))))
(define* (populate-system-generation-layout os rootfs closure-path
#:key
(generation-number 1)
install-spec
install-metadata-path)
(let* ((system-root (string-append rootfs "/var/lib/fruix/system"))
(generation-name (number->string generation-number))
(generation-link-target (string-append "generations/" generation-name))
(generation-dir (string-append system-root "/generations/" generation-name))
(gcroots-dir (string-append rootfs "/frx/var/fruix/gcroots"))
(generation-install-file (string-append generation-dir "/install.scm"))
(root-install-file (and install-metadata-path
(string-append rootfs install-metadata-path))))
(mkdir-p generation-dir)
(symlink-force closure-path (string-append generation-dir "/closure"))
(write-file (string-append generation-dir "/metadata.scm")
(object->string
(system-generation-metadata-object os closure-path
#:generation-number generation-number
#:install-spec install-spec
#:install-metadata-path install-metadata-path)))
(write-file (string-append generation-dir "/provenance.scm")
(object->string (system-generation-provenance-object closure-path)))
(when (and root-install-file (file-exists? root-install-file))
(copy-regular-file root-install-file generation-install-file)
(chmod generation-install-file #o644))
(symlink-force generation-link-target (string-append system-root "/current"))
(write-file (string-append system-root "/current-generation")
(string-append generation-name "\n"))
(mkdir-p gcroots-dir)
(symlink-force closure-path (string-append gcroots-dir "/system-" generation-name))
(symlink-force closure-path (string-append gcroots-dir "/current-system"))))
(define* (populate-rootfs-from-closure os rootfs closure-path
#:key
install-spec
install-metadata-path)
(when (file-exists? rootfs)
(delete-file-recursively rootfs))
(mkdir-p rootfs)
@@ -277,6 +349,9 @@
(string-append rootfs "/usr/local/etc/rc.d/fruix-activate"))
(symlink-force "/run/current-system/usr/local/etc/rc.d/fruix-shepherd"
(string-append rootfs "/usr/local/etc/rc.d/fruix-shepherd"))
(populate-system-generation-layout os rootfs closure-path
#:install-spec install-spec
#:install-metadata-path install-metadata-path)
`((rootfs . ,rootfs)
(closure-path . ,closure-path)
(ready-marker . ,(operating-system-ready-marker os))
@@ -459,9 +534,39 @@
#:serial-console serial-console))
(target-install . ,target-install-spec))))
(define* (operating-system-installer-iso-spec os
#:key
(install-target-device "/dev/vtbd0")
(installer-host-name (string-append (operating-system-host-name os)
"-installer"))
(root-size #f)
(iso-volume-label "FRUIX_INSTALLER")
(installer-root-partition-label "fruix-installer-root")
(target-efi-partition-label "efiboot")
(target-root-partition-label "fruix-root")
(serial-console "comconsole"))
(let ((target-install-spec (operating-system-install-spec os
#:target install-target-device
#:target-kind 'block-device
#:efi-size "64m"
#:root-size #f
#:disk-capacity #f
#:efi-partition-label target-efi-partition-label
#:root-partition-label target-root-partition-label
#:serial-console serial-console)))
`((installer-host-name . ,installer-host-name)
(install-target-device . ,install-target-device)
(boot-mode . uefi)
(image-format . iso9660)
(iso-volume-label . ,iso-volume-label)
(root-size . ,root-size)
(installer-root-partition-label . ,installer-root-partition-label)
(target-install . ,target-install-spec))))
(define image-builder-version "2")
(define install-builder-version "1")
(define installer-image-builder-version "1")
(define installer-iso-builder-version "2")
(define (operating-system-install-metadata-object install-spec closure-path store-items)
`((install-version . ,install-builder-version)
@@ -543,9 +648,10 @@
" [ -n \"$item_base\" ] || continue\n"
" (cd '" store-dir "' && pax -rw -pe \"$item_base\" \"$mnt_root" store-dir "\")\n"
"done <\"$store_items_file\"\n"
"mkdir -p \"$mnt_root/var/lib/fruix\" \"$mnt_esp/EFI/BOOT\"\n"
"mkdir -p \"$mnt_root/var/lib/fruix\" \"$mnt_root/var/lib/fruix/system/generations/1\" \"$mnt_esp/EFI/BOOT\"\n"
"cp \"$target_loader_efi\" \"$mnt_esp/EFI/BOOT/BOOTX64.EFI\"\n"
"cp \"$install_metadata_source\" \"$mnt_root/var/lib/fruix/install.scm\"\n"
"cp \"$install_metadata_source\" \"$mnt_root/var/lib/fruix/system/generations/1/install.scm\"\n"
"sync\n"
"echo 'fruix-installer:done'\n"
"write_state done\n")))
@@ -643,7 +749,9 @@
(dynamic-wind
(lambda () #t)
(lambda ()
(populate-rootfs-from-closure os rootfs closure-path)
(populate-rootfs-from-closure os rootfs closure-path
#:install-spec install-spec
#:install-metadata-path install-metadata-relative-path)
(mkdir-p mnt-root)
(mkdir-p mnt-esp)
(case target-kind
@@ -685,7 +793,10 @@
(write-file install-metadata-file
(object->string
(operating-system-install-metadata-object install-spec closure-path store-items)))
(chmod install-metadata-file #o644))
(chmod install-metadata-file #o644)
(populate-system-generation-layout os mnt-root closure-path
#:install-spec install-spec
#:install-metadata-path install-metadata-relative-path))
(run-command "sync")
`((target . ,target)
(target-kind . ,target-kind)
@@ -754,7 +865,7 @@
"\nstore-items=\n"
(string-join store-items "\n")
"\n"))
(hash (string-hash manifest))
(hash (sha256-string manifest))
(image-store-path (string-append store-dir "/" hash "-fruix-bhyve-image-"
(operating-system-host-name os)))
(disk-image (string-append image-store-path "/disk.img"))
@@ -908,7 +1019,7 @@
"\ninstall-metadata=\n"
(object->string install-metadata)
"\n"))
(hash (string-hash manifest))
(hash (sha256-string manifest))
(image-store-path (string-append store-dir "/" hash "-fruix-installer-image-"
(operating-system-host-name installer-os)))
(disk-image (string-append image-store-path "/disk.img"))
@@ -929,7 +1040,9 @@
(lambda () #t)
(lambda ()
(populate-rootfs-from-closure installer-os installer-rootfs installer-closure-path)
(populate-rootfs-from-closure os target-rootfs target-closure-path)
(populate-rootfs-from-closure os target-rootfs target-closure-path
#:install-spec target-install-spec
#:install-metadata-path "/var/lib/fruix/install.scm")
(copy-rootfs-for-image installer-rootfs image-rootfs)
(mkdir-p plan-root)
(mkdir-p (string-append image-rootfs "/usr/local/libexec"))
@@ -1029,3 +1142,297 @@
(store-items . ,combined-store-items)
(target-store-items . ,target-store-items)
(installer-store-items . ,installer-store-items))))
(define (resolved-path path)
(let ((target (false-if-exception (readlink path))))
(if target
(if (string-prefix? "/" target)
target
(string-append (dirname path) "/" target))
path)))
(define (copy-resolved-node source destination)
(copy-node (resolved-path source) destination))
(define (sanitize-iso-volume-label label)
(let* ((text (if (and (string? label) (not (string-null? label)))
label
"FRUIX_INSTALLER"))
(upper (string-upcase text))
(chars (map (lambda (ch)
(if (or (char-alphabetic? ch)
(char-numeric? ch)
(memv ch '(#\_ #\-)))
ch
#\_))
(string->list upper)))
(sanitized (list->string chars)))
(if (> (string-length sanitized) 32)
(substring sanitized 0 32)
sanitized)))
(define (source-store-item? item)
(string-contains (path-basename item) "-freebsd-source-"))
(define (runtime-store-items items)
(filter (lambda (item)
(not (source-store-item? item)))
items))
(define (write-installer-iso-loader-conf source-path destination)
(let* ((mode (stat:perms (stat source-path)))
(base (call-with-input-file source-path get-string-all))
(extra (string-append
"mdroot_load=\"YES\"\n"
"mdroot_type=\"mfs_root\"\n"
"mdroot_name=\"/boot/root.img\"\n"
"vfs.root.mountfrom=\"ufs:/dev/md0\"\n"
"vfs.root.mountfrom.options=\"rw\"\n")))
(write-file destination
(string-append base
(if (or (string-null? base)
(char=? (string-ref base (- (string-length base) 1)) #\newline))
""
"\n")
extra))
(chmod destination mode)))
(define (rewrite-installer-iso-fstab image-rootfs installer-closure-path)
(let ((fstab-path (string-append image-rootfs "/frx/store/"
(path-basename installer-closure-path)
"/etc/fstab")))
(rewrite-text-file fstab-path
'(("/dev/gpt/fruix-installer-root\t/\tufs"
. "/dev/md0\t/\tufs")))))
(define* (make-ufs-image output-path source-root label #:key size)
(apply run-command
(append (list "makefs" "-t" "ffs" "-T" "0" "-B" "little")
(if size
(list "-s" size)
'())
(list "-o" (string-append "label=" label
",version=2,bsize=32768,fsize=4096,density=16384")
output-path
source-root))))
(define (make-efi-boot-image loader-efi output-path)
(let ((stage-root (mktemp-directory "/tmp/fruix-installer-iso-esp.XXXXXX")))
(dynamic-wind
(lambda () #t)
(lambda ()
(mkdir-p (string-append stage-root "/EFI/BOOT"))
(copy-regular-file loader-efi
(string-append stage-root "/EFI/BOOT/BOOTX64.EFI"))
(run-command "makefs" "-t" "msdos" "-T" "0"
"-o" "fat_type=12"
"-o" "sectors_per_cluster=1"
"-o" "volume_label=EFISYS"
"-s" "2048k"
output-path stage-root))
(lambda ()
(when (file-exists? stage-root)
(delete-file-recursively stage-root))))))
(define (populate-installer-iso-boot-tree installer-closure-path iso-root root-image-path)
(let ((boot-root (string-append iso-root "/boot")))
(mkdir-p (string-append boot-root "/kernel"))
(copy-resolved-node (string-append installer-closure-path "/boot/kernel/kernel")
(string-append boot-root "/kernel/kernel"))
(copy-resolved-node (string-append installer-closure-path "/boot/kernel/linker.hints")
(string-append boot-root "/kernel/linker.hints"))
(copy-resolved-node (string-append installer-closure-path "/boot/loader")
(string-append boot-root "/loader"))
(copy-resolved-node (string-append installer-closure-path "/boot/loader.efi")
(string-append boot-root "/loader.efi"))
(copy-resolved-node (string-append installer-closure-path "/boot/device.hints")
(string-append boot-root "/device.hints"))
(copy-resolved-node (string-append installer-closure-path "/boot/defaults")
(string-append boot-root "/defaults"))
(copy-resolved-node (string-append installer-closure-path "/boot/lua")
(string-append boot-root "/lua"))
(write-installer-iso-loader-conf (string-append installer-closure-path "/boot/loader.conf")
(string-append boot-root "/loader.conf"))
(copy-regular-file root-image-path
(string-append boot-root "/root.img"))))
(define* (materialize-installer-iso os
#:key
(store-dir "/frx/store")
(guile-prefix "/tmp/guile-freebsd-validate-install")
(guile-extra-prefix "/tmp/guile-gnutls-freebsd-validate-install")
(shepherd-prefix "/tmp/shepherd-freebsd-validate-install")
(install-target-device "/dev/vtbd0")
(root-size #f)
(installer-host-name (string-append (operating-system-host-name os)
"-installer"))
(installer-root-partition-label "fruix-installer-root")
(target-efi-partition-label "efiboot")
(target-root-partition-label "fruix-root")
(serial-console "comconsole")
(iso-volume-label "FRUIX_INSTALLER"))
(let* ((installer-os (installer-operating-system os
#:host-name installer-host-name
#:root-partition-label installer-root-partition-label))
(target-closure (materialize-operating-system os
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix))
(installer-closure (materialize-operating-system installer-os
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix))
(target-closure-path (assoc-ref target-closure 'closure-path))
(installer-closure-path (assoc-ref installer-closure 'closure-path))
(target-closure-store-items (store-reference-closure (list target-closure-path)))
(target-runtime-store-items (runtime-store-items target-closure-store-items))
(installer-store-items (runtime-store-items
(store-reference-closure (list installer-closure-path))))
(combined-store-items (delete-duplicates (append installer-store-items target-runtime-store-items)))
(sanitized-iso-volume-label (sanitize-iso-volume-label iso-volume-label))
(installer-iso-spec (operating-system-installer-iso-spec os
#:install-target-device install-target-device
#:installer-host-name installer-host-name
#:root-size root-size
#:iso-volume-label sanitized-iso-volume-label
#:installer-root-partition-label installer-root-partition-label
#:target-efi-partition-label target-efi-partition-label
#:target-root-partition-label target-root-partition-label
#:serial-console serial-console))
(target-install-spec (assoc-ref installer-iso-spec 'target-install))
(install-metadata (operating-system-install-metadata-object target-install-spec
target-closure-path
target-closure-store-items))
(installer-plan-directory "/var/lib/fruix/installer")
(installer-state-path (string-append installer-plan-directory "/state"))
(installer-log-path "/var/log/fruix-installer.log")
(manifest (string-append
"installer-iso-builder-version=\n"
installer-iso-builder-version
"\ninstaller-iso-spec=\n"
(object->string installer-iso-spec)
"installer-closure-path=\n"
installer-closure-path
"\ntarget-closure-path=\n"
target-closure-path
"\ncombined-store-items=\n"
(string-join combined-store-items "\n")
"\ntarget-store-items=\n"
(string-join target-closure-store-items "\n")
"\ninstall-metadata=\n"
(object->string install-metadata)
"\n"))
(hash (sha256-string manifest))
(iso-store-path (string-append store-dir "/" hash "-fruix-installer-iso-"
(operating-system-host-name installer-os)))
(iso-image (string-append iso-store-path "/installer.iso"))
(boot-efi-image (string-append iso-store-path "/efiboot.img"))
(root-image (string-append iso-store-path "/root.img")))
(unless (file-exists? iso-store-path)
(let* ((build-root (mktemp-directory "/tmp/fruix-installer-iso-build.XXXXXX"))
(installer-rootfs (string-append build-root "/installer-rootfs"))
(target-rootfs (string-append build-root "/target-rootfs"))
(image-rootfs (string-append build-root "/image-rootfs"))
(iso-root (string-append build-root "/iso-root"))
(temp-output (mktemp-directory (string-append store-dir "/.fruix-installer-iso.XXXXXX")))
(temp-iso (string-append build-root "/installer.iso"))
(temp-esp (string-append build-root "/efiboot.img"))
(temp-root (string-append build-root "/root.img"))
(plan-root (string-append image-rootfs installer-plan-directory)))
(dynamic-wind
(lambda () #t)
(lambda ()
(populate-rootfs-from-closure installer-os installer-rootfs installer-closure-path)
(populate-rootfs-from-closure os target-rootfs target-closure-path
#:install-spec target-install-spec
#:install-metadata-path "/var/lib/fruix/install.scm")
(copy-rootfs-for-image installer-rootfs image-rootfs)
(mkdir-p plan-root)
(mkdir-p (string-append image-rootfs "/usr/local/libexec"))
(mkdir-p (string-append image-rootfs "/usr/local/etc/rc.d"))
(mkdir-p (string-append plan-root "/target-rootfs"))
(copy-tree-contents target-rootfs (string-append plan-root "/target-rootfs"))
(copy-store-items-into-rootfs image-rootfs store-dir combined-store-items)
(write-file (string-append plan-root "/store-items")
(string-append (string-join (map path-basename target-runtime-store-items) "\n") "\n"))
(write-file (string-append plan-root "/install.scm")
(object->string install-metadata))
(copy-regular-file (string-append target-closure-path "/boot/loader.efi")
(string-append plan-root "/loader.efi"))
(write-file (string-append plan-root "/target-device")
(string-append install-target-device "\n"))
(write-file (string-append plan-root "/efi-size") "64m\n")
(write-file (string-append plan-root "/efi-partition-label")
(string-append target-efi-partition-label "\n"))
(write-file (string-append plan-root "/root-partition-label")
(string-append target-root-partition-label "\n"))
(write-file (string-append plan-root "/state") "pending\n")
(write-file (string-append image-rootfs "/usr/local/libexec/fruix-installer-run")
(render-installer-run-script store-dir installer-plan-directory))
(write-file (string-append image-rootfs "/usr/local/etc/rc.d/fruix-installer")
(render-installer-rc-script installer-plan-directory))
(chmod (string-append image-rootfs "/usr/local/libexec/fruix-installer-run") #o555)
(chmod (string-append image-rootfs "/usr/local/etc/rc.d/fruix-installer") #o555)
(rewrite-installer-iso-fstab image-rootfs installer-closure-path)
(make-ufs-image temp-root image-rootfs installer-root-partition-label #:size root-size)
(populate-installer-iso-boot-tree installer-closure-path iso-root temp-root)
(make-efi-boot-image (resolved-path (string-append installer-closure-path "/boot/loader.efi")) temp-esp)
(run-command "makefs" "-t" "cd9660" "-T" "0"
"-o" (string-append "bootimage=efi;" temp-esp)
"-o" "no-emul-boot"
"-o" "platformid=efi"
"-o" "rockridge"
"-o" (string-append "label=" sanitized-iso-volume-label)
temp-iso iso-root)
(mkdir-p temp-output)
(copy-regular-file temp-iso (string-append temp-output "/installer.iso"))
(copy-regular-file temp-esp (string-append temp-output "/efiboot.img"))
(copy-regular-file temp-root (string-append temp-output "/root.img"))
(write-file (string-append temp-output "/installer-iso-spec.scm")
(object->string installer-iso-spec))
(write-file (string-append temp-output "/installer-closure-path") installer-closure-path)
(write-file (string-append temp-output "/target-closure-path") target-closure-path)
(write-file (string-append temp-output "/.references")
(string-join combined-store-items "\n"))
(write-file (string-append temp-output "/.fruix-package") manifest)
(chmod temp-output #o755)
(for-each (lambda (path)
(chmod path #o644))
(list (string-append temp-output "/installer.iso")
(string-append temp-output "/efiboot.img")
(string-append temp-output "/root.img")
(string-append temp-output "/installer-iso-spec.scm")
(string-append temp-output "/installer-closure-path")
(string-append temp-output "/target-closure-path")
(string-append temp-output "/.references")
(string-append temp-output "/.fruix-package")))
(rename-file temp-output iso-store-path))
(lambda ()
(when (file-exists? build-root)
(delete-file-recursively build-root))))))
`((iso-store-path . ,iso-store-path)
(iso-image . ,iso-image)
(boot-efi-image . ,boot-efi-image)
(root-image . ,root-image)
(installer-closure-path . ,installer-closure-path)
(target-closure-path . ,target-closure-path)
(closure-path . ,installer-closure-path)
(installer-iso-spec . ,installer-iso-spec)
(install-spec . ,target-install-spec)
(installer-state-path . ,installer-state-path)
(installer-log-path . ,installer-log-path)
(install-target-device . ,install-target-device)
(host-base-stores . ,(assoc-ref target-closure 'host-base-stores))
(native-base-stores . ,(assoc-ref target-closure 'native-base-stores))
(fruix-runtime-stores . ,(assoc-ref target-closure 'fruix-runtime-stores))
(freebsd-base-file . ,(assoc-ref target-closure 'freebsd-base-file))
(freebsd-source-file . ,(assoc-ref target-closure 'freebsd-source-file))
(freebsd-source-materializations-file . ,(assoc-ref target-closure 'freebsd-source-materializations-file))
(materialized-source-stores . ,(assoc-ref target-closure 'materialized-source-stores))
(host-base-provenance-file . ,(assoc-ref target-closure 'host-base-provenance-file))
(store-layout-file . ,(assoc-ref target-closure 'store-layout-file))
(store-items . ,combined-store-items)
(target-store-items . ,target-closure-store-items)
(installer-store-items . ,installer-store-items))))

View File

@@ -36,7 +36,7 @@
(define (ensure-git-source-cache source cache-dir)
(let* ((url (freebsd-source-url source))
(repo-dir (string-append cache-dir "/git/"
(string-hash (string-append "git:" url))
(sha256-string (string-append "git:" url))
".git")))
(mkdir-p (dirname repo-dir))
(unless (file-exists? repo-dir)
@@ -84,7 +84,7 @@
(expected-sha256 (or (normalize-expected-sha256 source)
(error "src-txz freebsd source requires sha256 for materialization" source)))
(archive-path (string-append cache-dir "/archives/"
(string-hash (string-append "txz:" url))
(sha256-string (string-append "txz:" url))
"-src.txz")))
(mkdir-p (dirname archive-path))
(when (file-exists? archive-path)
@@ -150,7 +150,7 @@
(effective-source (assoc-ref resolution 'effective-source))
(identity (assoc-ref resolution 'identity))
(manifest (freebsd-source-manifest source effective-source identity))
(hash (string-hash manifest))
(hash (sha256-string manifest))
(output-path (string-append store-dir "/" hash "-freebsd-source-"
(safe-name-fragment (freebsd-source-name source))))
(info-file (string-append output-path "/.freebsd-source-info.scm"))

View File

@@ -13,7 +13,7 @@
command-output
safe-command-output
write-file
string-hash
sha256-string
file-hash
directory-entries
path-signature
@@ -63,7 +63,7 @@
(lambda (port)
(display content port))))
(define (string-hash text)
(define (sha256-string text)
(let* ((tmp (string-append (getenv* "TMPDIR" "/tmp") "/fruix-system-hash.txt")))
(write-file tmp text)
(command-output "sha256" "-q" tmp)))
@@ -110,7 +110,7 @@
(stable-lines (filter (lambda (line)
(not (string-prefix? "#" line)))
(string-split mtree-output #\newline))))
(string-hash (string-join stable-lines "\n"))))
(sha256-string (string-join stable-lines "\n"))))
(define (copy-regular-file source destination)
(let ((mode (stat:perms (stat source))))

View File

@@ -20,6 +20,7 @@ System actions:\n\
build Materialize the Fruix system closure in /frx/store.\n\
image Materialize the Fruix disk image in /frx/store.\n\
installer Materialize a bootable Fruix installer image in /frx/store.\n\
installer-iso Materialize a bootable Fruix installer ISO in /frx/store.\n\
install Install the Fruix system onto --target PATH.\n\
rootfs Materialize a rootfs tree at --rootfs DIR or ROOTFS-DIR.\n\
\n\
@@ -27,7 +28,7 @@ System options:\n\
--system NAME Scheme variable holding the operating-system object.\n\
--store DIR Store directory to use (default: /frx/store).\n\
--disk-capacity SIZE Disk capacity for 'image', 'installer', or raw-file 'install' targets.\n\
--root-size SIZE Root filesystem size for 'image', 'installer', or 'install' (example: 6g).\n\
--root-size SIZE Root filesystem size for 'image', 'installer', 'installer-iso', or 'install' (example: 6g).\n\
--target PATH Install target for 'install' (raw image file or /dev/... device).\n\
--install-target-device DEVICE\n\
Target block device used by the booted 'installer' environment.\n\
@@ -476,6 +477,71 @@ Common options:\n\
(target_store_item_count . ,(length target-store-items))
(installer_store_item_count . ,(length installer-store-items))))))
(define (emit-system-installer-iso-metadata os-file resolved-symbol store-dir os result)
(let* ((installer-iso-spec (assoc-ref result 'installer-iso-spec))
(store-items (assoc-ref result 'store-items))
(target-store-items (assoc-ref result 'target-store-items))
(installer-store-items (assoc-ref result 'installer-store-items))
(host-base-stores (assoc-ref result 'host-base-stores))
(native-base-stores (assoc-ref result 'native-base-stores))
(fruix-runtime-stores (assoc-ref result 'fruix-runtime-stores))
(base (operating-system-freebsd-base os))
(source (freebsd-base-source base))
(host-provenance (call-with-input-file (assoc-ref result 'host-base-provenance-file) read)))
(emit-metadata
`((action . "installer-iso")
(os_file . ,os-file)
(system_variable . ,resolved-symbol)
(store_dir . ,store-dir)
(freebsd_base_name . ,(freebsd-base-name base))
(freebsd_base_version_label . ,(freebsd-base-version-label base))
(freebsd_base_release . ,(freebsd-base-release base))
(freebsd_base_branch . ,(freebsd-base-branch base))
(freebsd_base_source_root . ,(freebsd-base-source-root base))
(freebsd_base_target . ,(freebsd-base-target base))
(freebsd_base_target_arch . ,(freebsd-base-target-arch base))
(freebsd_base_kernconf . ,(freebsd-base-kernconf base))
(freebsd_base_file . ,(assoc-ref result 'freebsd-base-file))
(freebsd_source_name . ,(freebsd-source-name source))
(freebsd_source_kind . ,(freebsd-source-kind source))
(freebsd_source_url . ,(or (freebsd-source-url source) ""))
(freebsd_source_path . ,(or (freebsd-source-path source) ""))
(freebsd_source_ref . ,(or (freebsd-source-ref source) ""))
(freebsd_source_commit . ,(or (freebsd-source-commit source) ""))
(freebsd_source_sha256 . ,(or (freebsd-source-sha256 source) ""))
(freebsd_source_file . ,(assoc-ref result 'freebsd-source-file))
(freebsd_source_materializations_file . ,(assoc-ref result 'freebsd-source-materializations-file))
(materialized_source_store_count . ,(length (assoc-ref result 'materialized-source-stores)))
(materialized_source_stores . ,(string-join (assoc-ref result 'materialized-source-stores) ","))
(installer_host_name . ,(assoc-ref installer-iso-spec 'installer-host-name))
(install_target_device . ,(assoc-ref result 'install-target-device))
(iso_volume_label . ,(assoc-ref installer-iso-spec 'iso-volume-label))
(root_size . ,(assoc-ref installer-iso-spec 'root-size))
(installer_state_path . ,(assoc-ref result 'installer-state-path))
(installer_log_path . ,(assoc-ref result 'installer-log-path))
(iso_store_path . ,(assoc-ref result 'iso-store-path))
(iso_image . ,(assoc-ref result 'iso-image))
(boot_efi_image . ,(assoc-ref result 'boot-efi-image))
(root_image . ,(assoc-ref result 'root-image))
(installer_closure_path . ,(assoc-ref result 'installer-closure-path))
(target_closure_path . ,(assoc-ref result 'target-closure-path))
(host_base_store_count . ,(length host-base-stores))
(host_base_stores . ,(string-join host-base-stores ","))
(native_base_store_count . ,(length native-base-stores))
(native_base_stores . ,(string-join native-base-stores ","))
(fruix_runtime_store_count . ,(length fruix-runtime-stores))
(fruix_runtime_stores . ,(string-join fruix-runtime-stores ","))
(host_base_provenance_file . ,(assoc-ref result 'host-base-provenance-file))
(store_layout_file . ,(assoc-ref result 'store-layout-file))
(host_freebsd_version . ,(assoc-ref host-provenance 'freebsd-version-kru))
(host_uname . ,(assoc-ref host-provenance 'uname))
(usr_src_git_revision . ,(assoc-ref host-provenance 'usr-src-git-revision))
(usr_src_git_branch . ,(assoc-ref host-provenance 'usr-src-git-branch))
(usr_src_newvers_sha256 . ,(assoc-ref host-provenance 'usr-src-newvers-sha256))
(store_item_count . ,(length store-items))
(target_store_item_count . ,(length target-store-items))
(installer_store_item_count . ,(length installer-store-items))))))
(define (main argv)
(let* ((parsed (parse-arguments argv))
(command (assoc-ref parsed 'command))
@@ -491,7 +557,7 @@ Common options:\n\
(rootfs-opt (assoc-ref parsed 'rootfs))
(system-name (assoc-ref parsed 'system-name))
(requested-symbol (and system-name (string->symbol system-name))))
(unless (member action '("build" "image" "installer" "install" "rootfs"))
(unless (member action '("build" "image" "installer" "installer-iso" "install" "rootfs"))
(error "unknown system action" action))
(let* ((os-file (match positional
((file . _) file)
@@ -561,6 +627,16 @@ Common options:\n\
#:install-target-device (or install-target-device "/dev/vtbd1")
#:root-size (or root-size "10g")
#:disk-capacity disk-capacity)))
((string=? action "installer-iso")
(emit-system-installer-iso-metadata
os-file resolved-symbol store-dir os
(materialize-installer-iso os
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix
#:install-target-device (or install-target-device "/dev/vtbd0")
#:root-size root-size)))
((string=? action "install")
(unless target
(error "install action requires TARGET or --target PATH"))

View File

@@ -0,0 +1,455 @@
#!/bin/sh
set -eu
repo_root=${PROJECT_ROOT:-$(pwd)}
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
fruix_cmd=$repo_root/bin/fruix
vm_id=${VM_ID:-90490f2e-e8fc-4b7a-388e-5c26f0157289}
iso_sr_id=${ISO_SR_ID:-537a6219-8452-7cb5-8d56-5eed6910c7a2}
os_template=${OS_TEMPLATE:-$script_dir/phase18-installer-target-operating-system.scm.in}
system_name=${SYSTEM_NAME:-phase18-target-operating-system}
store_dir=${STORE_DIR:-/frx/store}
install_target_device=${INSTALL_TARGET_DEVICE:-/dev/ada0}
installer_root_size=${INSTALLER_ROOT_SIZE:-}
base_name=${BASE_NAME:-phase18-installer-iso-target}
base_version_label=${BASE_VERSION_LABEL:-15.0-STABLE-installer-iso-target}
base_release=${BASE_RELEASE:-15.0-STABLE}
base_branch=${BASE_BRANCH:-stable/15}
source_name=${SOURCE_NAME:-stable15-installer-iso-target-source}
source_ref=${SOURCE_REF:-stable/15}
source_commit=${SOURCE_COMMIT:-332708a606f6bf0841c1d4a74c0d067f5640fe89}
declared_source_root=${DECLARED_SOURCE_ROOT:-/var/empty/fruix-unused-source-root-installer-iso-target}
metadata_target=${METADATA_OUT:-}
root_authorized_key_file=${ROOT_AUTHORIZED_KEY_FILE:-$HOME/.ssh/id_ed25519.pub}
root_ssh_private_key_file=${ROOT_SSH_PRIVATE_KEY_FILE:-$HOME/.ssh/id_ed25519}
iso_http_port=${ISO_HTTP_PORT:-$(jot -r 1 18080 18999)}
keep_imported_iso=${KEEP_IMPORTED_ISO:-0}
[ -x "$fruix_cmd" ] || {
echo "fruix command is not executable: $fruix_cmd" >&2
exit 1
}
[ -f "$os_template" ] || {
echo "missing operating-system template: $os_template" >&2
exit 1
}
[ -f "$root_authorized_key_file" ] || {
echo "missing root authorized key file: $root_authorized_key_file" >&2
exit 1
}
[ -f "$root_ssh_private_key_file" ] || {
echo "missing root SSH private key file: $root_ssh_private_key_file" >&2
exit 1
}
command -v node >/dev/null 2>&1 || {
echo "node is required to serve the ISO for XO import" >&2
exit 1
}
command -v xo-cli >/dev/null 2>&1 || {
echo "xo-cli is required" >&2
exit 1
}
command -v jq >/dev/null 2>&1 || {
echo "jq is required" >&2
exit 1
}
cleanup=0
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-phase18-installer-iso-xcpng.XXXXXX)
cleanup=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup=0
fi
target_os_file=$workdir/phase18-installer-iso-target-operating-system.scm
installer_out=$workdir/installer-iso.txt
metadata_file=$workdir/phase18-installer-iso-xcpng-metadata.txt
vm_info_json=$workdir/vm-info.json
vdi_info_json=$workdir/vdi-info.json
installer_log_file=$workdir/installer.log
installer_gpart_file=$workdir/installer-gpart.txt
installer_kern_disks_file=$workdir/installer-kern-disks.txt
target_install_metadata_file=$workdir/target-install.scm
server_script=$workdir/serve-iso.mjs
server_log=$workdir/serve-iso.log
arp_scan_log=$workdir/arp-scan.log
server_pid=
imported_iso_id=
imported_iso_name=
guest_ip=
vm_mac=
cleanup_workdir() {
if [ -n "$server_pid" ]; then
kill "$server_pid" >/dev/null 2>&1 || true
fi
xo-cli vm.ejectCd id=$vm_id >/dev/null 2>&1 || true
if [ -n "$imported_iso_id" ] && [ "$keep_imported_iso" -ne 1 ]; then
xo-cli vdi.delete id=$imported_iso_id >/dev/null 2>&1 || true
fi
if [ "$cleanup" -eq 1 ]; then
rm -rf "$workdir"
fi
}
trap cleanup_workdir EXIT INT TERM
root_authorized_key=$(tr -d '\n' < "$root_authorized_key_file")
sed \
-e "s|__BASE_NAME__|$base_name|g" \
-e "s|__BASE_VERSION_LABEL__|$base_version_label|g" \
-e "s|__BASE_RELEASE__|$base_release|g" \
-e "s|__BASE_BRANCH__|$base_branch|g" \
-e "s|__SOURCE_NAME__|$source_name|g" \
-e "s|__SOURCE_REF__|$source_ref|g" \
-e "s|__SOURCE_COMMIT__|$source_commit|g" \
-e "s|__DECLARED_SOURCE_ROOT__|$declared_source_root|g" \
-e "s|__ROOT_AUTHORIZED_KEY__|$root_authorized_key|g" \
"$os_template" > "$target_os_file"
action_env() {
sudo env \
HOME="$HOME" \
GUILE_AUTO_COMPILE=0 \
FRUIX_FREEBSD_BUILD_JOBS="${FRUIX_FREEBSD_BUILD_JOBS:-8}" \
GUIX_SOURCE_DIR="${GUIX_SOURCE_DIR:-$HOME/repos/guix}" \
GUILE_BIN="${GUILE_BIN:-/tmp/guile-freebsd-validate-install/bin/guile}" \
GUILE_EXTRA_PREFIX="${GUILE_EXTRA_PREFIX:-/tmp/guile-gnutls-freebsd-validate-install}" \
SHEPHERD_PREFIX="${SHEPHERD_PREFIX:-/tmp/shepherd-freebsd-validate-install}" \
"$@"
}
if [ -n "$installer_root_size" ]; then
action_env "$fruix_cmd" system installer-iso "$target_os_file" \
--system "$system_name" \
--store "$store_dir" \
--install-target-device "$install_target_device" \
--root-size "$installer_root_size" >"$installer_out"
else
action_env "$fruix_cmd" system installer-iso "$target_os_file" \
--system "$system_name" \
--store "$store_dir" \
--install-target-device "$install_target_device" >"$installer_out"
fi
field() {
sed -n "s/^$1=//p" "$installer_out" | tail -n 1
}
iso_store_path=$(field iso_store_path)
installer_iso_image=$(field iso_image)
installer_boot_efi_image=$(field boot_efi_image)
installer_root_image=$(field root_image)
installer_closure_path=$(field installer_closure_path)
target_closure_path=$(field target_closure_path)
installer_host_name=$(field installer_host_name)
install_target_device_out=$(field install_target_device)
installer_state_path=$(field installer_state_path)
installer_log_path=$(field installer_log_path)
iso_volume_label=$(field iso_volume_label)
root_size_out=$(field root_size)
freebsd_source_kind_out=$(field freebsd_source_kind)
freebsd_source_ref_out=$(field freebsd_source_ref)
freebsd_source_commit_out=$(field freebsd_source_commit)
freebsd_source_file=$(field freebsd_source_file)
freebsd_source_materializations_file=$(field freebsd_source_materializations_file)
materialized_source_store_count=$(field materialized_source_store_count)
materialized_source_stores=$(field materialized_source_stores)
host_base_store_count=$(field host_base_store_count)
native_base_store_count=$(field native_base_store_count)
native_base_stores=$(field native_base_stores)
store_item_count=$(field store_item_count)
target_store_item_count=$(field target_store_item_count)
installer_store_item_count=$(field installer_store_item_count)
store_layout_file=$(field store_layout_file)
[ -d "$iso_store_path" ] || { echo "missing installer ISO store path: $iso_store_path" >&2; exit 1; }
[ -f "$installer_iso_image" ] || { echo "missing installer ISO image: $installer_iso_image" >&2; exit 1; }
[ -f "$installer_boot_efi_image" ] || { echo "missing installer EFI boot image: $installer_boot_efi_image" >&2; exit 1; }
[ -f "$installer_root_image" ] || { echo "missing installer root image: $installer_root_image" >&2; exit 1; }
[ -n "$installer_closure_path" ] || { echo "missing installer closure path" >&2; exit 1; }
[ -n "$target_closure_path" ] || { echo "missing target closure path" >&2; exit 1; }
[ "$install_target_device_out" = "$install_target_device" ] || { echo "unexpected install target device: $install_target_device_out" >&2; exit 1; }
[ "$installer_host_name" = fruix-freebsd-installer ] || { echo "unexpected installer host name: $installer_host_name" >&2; exit 1; }
[ -n "$iso_volume_label" ] || { echo "missing ISO volume label" >&2; exit 1; }
[ "$freebsd_source_kind_out" = git ] || { echo "unexpected source kind: $freebsd_source_kind_out" >&2; exit 1; }
[ "$freebsd_source_ref_out" = "$source_ref" ] || { echo "unexpected source ref: $freebsd_source_ref_out" >&2; exit 1; }
[ "$freebsd_source_commit_out" = "$source_commit" ] || { echo "unexpected source commit: $freebsd_source_commit_out" >&2; exit 1; }
[ "$materialized_source_store_count" = 1 ] || { echo "unexpected materialized source store count: $materialized_source_store_count" >&2; exit 1; }
[ "$host_base_store_count" = 0 ] || { echo "expected zero host base stores, got: $host_base_store_count" >&2; exit 1; }
[ "$native_base_store_count" = 3 ] || { echo "expected three native base stores, got: $native_base_store_count" >&2; exit 1; }
[ -f "$freebsd_source_file" ] || { echo "missing freebsd source file: $freebsd_source_file" >&2; exit 1; }
[ -f "$freebsd_source_materializations_file" ] || { echo "missing source materializations file: $freebsd_source_materializations_file" >&2; exit 1; }
[ -f "$store_layout_file" ] || { echo "missing store layout file: $store_layout_file" >&2; exit 1; }
case "$materialized_source_stores" in
/frx/store/*-freebsd-source-$source_name) : ;;
*) echo "unexpected materialized source store path: $materialized_source_stores" >&2; exit 1 ;;
esac
[ "$store_item_count" -ge "$target_store_item_count" ] || { echo "combined store item count smaller than target store item count" >&2; exit 1; }
[ "$installer_store_item_count" -ge 1 ] || { echo "expected installer store items" >&2; exit 1; }
xo-cli list-objects id=$vm_id >"$vm_info_json"
vdi_id=$(xo-cli list-objects type=VBD | jq -r '.[] | select(.VM=="'$vm_id'" and .is_cd_drive==false and .position=="0") | .VDI' | head -n 1)
secondary_vdi_id=$(xo-cli list-objects type=VBD | jq -r '.[] | select(.VM=="'$vm_id'" and .is_cd_drive==false and .position=="1") | .VDI' | head -n 1)
[ -n "$vdi_id" ] || { echo "failed to discover primary target VDI for VM $vm_id" >&2; exit 1; }
xo-cli list-objects type=VDI | jq '[.[] | select(.id=="'$vdi_id'")]' >"$vdi_info_json"
vdi_size=$(jq -r '.[0].size' "$vdi_info_json")
[ -n "$vdi_size" ] || { echo "failed to discover VDI size for $vdi_id" >&2; exit 1; }
vif_id=$(jq -r '.[0].VIFs[0]' "$vm_info_json")
if [ -n "$vif_id" ] && [ "$vif_id" != null ]; then
vm_mac=$(xo-cli list-objects type=VIF | jq -r '.[] | select(.id=="'$vif_id'") | .MAC' | tr 'A-Z' 'a-z')
else
vm_mac=
fi
[ -n "$vm_mac" ] || { echo "failed to discover VM MAC address" >&2; exit 1; }
host_interface=$(route -n get default | awk '/interface:/{print $2; exit}')
host_ip=$(ifconfig "$host_interface" | awk '/inet /{print $2; exit}')
subnet_prefix=${host_ip%.*}
cat >"$server_script" <<'EOF'
import { createServer } from 'node:http'
import { createReadStream, statSync } from 'node:fs'
const file = process.argv[2]
const port = Number(process.argv[3])
const size = statSync(file).size
createServer((req, res) => {
if (req.url !== '/installer.iso') {
res.statusCode = 404
res.end('not found\n')
return
}
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': size,
'Content-Disposition': 'attachment; filename="installer.iso"'
})
createReadStream(file).pipe(res)
}).listen(port, '0.0.0.0', () => {
console.log(`listening ${port}`)
})
EOF
node "$server_script" "$installer_iso_image" "$iso_http_port" >"$server_log" 2>&1 &
server_pid=$!
for _ in $(jot 50 1 50); do
if grep -q 'listening' "$server_log"; then
break
fi
sleep 1
if ! kill -0 "$server_pid" >/dev/null 2>&1; then
echo "temporary ISO HTTP server failed to start" >&2
cat "$server_log" >&2 || true
exit 1
fi
done
iso_import_url="http://$host_ip:$iso_http_port/installer.iso"
imported_iso_name="fruix-installer-iso-ada0-$(date +%s).iso"
imported_iso_id=$(xo-cli disk.import name="$imported_iso_name" sr="$iso_sr_id" type=iso url="$iso_import_url")
kill "$server_pid" >/dev/null 2>&1 || true
server_pid=
refresh_guest_ip() {
guest_ip=$(arp -an | awk -v mac="$vm_mac" 'tolower($4)==mac {gsub(/[()]/,"",$2); print $2; exit}')
}
ping_sweep() {
: >"$arp_scan_log"
for host in $(jot 254 1 254); do
ip=$subnet_prefix.$host
(
ping -c 1 -W 1000 "$ip" >/dev/null 2>&1 && echo "$ip" >>"$arp_scan_log"
) &
done
wait
}
ssh_guest() {
ssh -i "$root_ssh_private_key_file" \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
-o ConnectTimeout=5 \
root@"$guest_ip" "$@"
}
wait_for_guest_command() {
probe=$1
attempts=$2
delay=$3
guest_ip=
for attempt in $(jot "$attempts" 1 "$attempts"); do
refresh_guest_ip
if [ -z "$guest_ip" ]; then
ping_sweep
refresh_guest_ip
fi
if [ -n "$guest_ip" ]; then
if ssh_guest "$probe" >/dev/null 2>&1; then
return 0
fi
fi
sleep "$delay"
done
return 1
}
xo-cli vm.stop id=$vm_id force=true >/dev/null 2>&1 || true
xo-cli vm.insertCd id=$vm_id cd_id=$imported_iso_id force=true >"$workdir/insert-cd.out"
xo-cli vm.setBootOrder vm=$vm_id order=dcn >"$workdir/set-boot-order.out"
xo-cli vm.start id=$vm_id >"$workdir/vm-start-installer.out"
wait_for_guest_command 'test -e /var/lib/fruix/installer/state' 90 5 || {
echo "installer ISO guest never became reachable over SSH" >&2
exit 1
}
installer_state=missing
for attempt in $(jot 180 1 180); do
if ssh_guest 'test -e /var/lib/fruix/installer/state' >/dev/null 2>&1; then
installer_state=$(ssh_guest "cat '$installer_state_path' 2>/dev/null || echo missing")
[ "$installer_state" = done ] && break
fi
sleep 5
done
installer_run_current_system=$(ssh_guest 'readlink /run/current-system')
installer_sshd_status=$(ssh_guest 'service sshd onestatus >/dev/null 2>&1 && echo running || echo stopped')
installer_log=$(ssh_guest "cat '$installer_log_path' 2>/dev/null || true")
installer_target_device=$(ssh_guest 'cat /var/lib/fruix/installer/target-device')
installer_kern_disks=$(ssh_guest 'sysctl -n kern.disks')
installer_gpart=$(ssh_guest 'gpart show ada0')
installer_esp_fstype=$(ssh_guest 'fstyp /dev/ada0p1')
installer_root_fstype=$(ssh_guest 'fstyp /dev/ada0p2')
printf '%s\n' "$installer_log" >"$installer_log_file"
printf '%s\n' "$installer_kern_disks" >"$installer_kern_disks_file"
printf '%s\n' "$installer_gpart" >"$installer_gpart_file"
[ "$installer_state" = done ] || { echo "installer ISO environment did not finish installation: $installer_state" >&2; exit 1; }
[ "$installer_run_current_system" = "/frx/store/$(basename "$installer_closure_path")" ] || { echo "unexpected installer current-system target: $installer_run_current_system" >&2; exit 1; }
[ "$installer_sshd_status" = running ] || { echo "installer sshd is not running" >&2; exit 1; }
[ "$installer_target_device" = "$install_target_device" ] || { echo "unexpected installer target device in guest: $installer_target_device" >&2; exit 1; }
[ "$installer_esp_fstype" = msdosfs ] || { echo "unexpected target ESP filesystem: $installer_esp_fstype" >&2; exit 1; }
[ "$installer_root_fstype" = ufs ] || { echo "unexpected target root filesystem: $installer_root_fstype" >&2; exit 1; }
case "$installer_log" in
*fruix-installer:done*) : ;;
*) echo "installer log does not show completion" >&2; exit 1 ;;
esac
case "$installer_kern_disks" in
*cd0*ada0*) : ;;
*) echo "unexpected installer kern.disks output: $installer_kern_disks" >&2; exit 1 ;;
esac
case "$installer_gpart" in
*ada0*GPT*) : ;;
*) echo "unexpected gpart output for ada0" >&2; exit 1 ;;
esac
xo-cli vm.ejectCd id=$vm_id >"$workdir/eject-cd.out"
xo-cli vm.restart id=$vm_id force=true >"$workdir/vm-restart-target.out"
wait_for_guest_command 'test -f /var/lib/fruix/ready' 120 5 || {
echo "installed target never became reachable over SSH" >&2
exit 1
}
target_run_current_system=$(ssh_guest 'readlink /run/current-system')
target_shepherd_status=$(ssh_guest '/usr/local/etc/rc.d/fruix-shepherd onestatus >/dev/null 2>&1 && echo running || echo stopped')
target_sshd_status=$(ssh_guest 'service sshd onestatus >/dev/null 2>&1 && echo running || echo stopped')
target_activate_log=$(ssh_guest 'cat /var/log/fruix-activate.log 2>/dev/null || true' | tr '\n' ' ')
target_install_metadata=$(ssh_guest 'cat /var/lib/fruix/install.scm')
printf '%s\n' "$target_install_metadata" >"$target_install_metadata_file"
[ "$target_run_current_system" = "/frx/store/$(basename "$target_closure_path")" ] || { echo "unexpected booted target current-system: $target_run_current_system" >&2; exit 1; }
[ "$target_shepherd_status" = running ] || { echo "fruix-shepherd is not running in booted target" >&2; exit 1; }
[ "$target_sshd_status" = running ] || { echo "sshd is not running in booted target" >&2; exit 1; }
case "$target_install_metadata" in
*"$target_closure_path"*) : ;;
*) echo "booted target metadata does not record target closure path" >&2; exit 1 ;;
esac
case "$target_install_metadata" in
*"$materialized_source_stores"*) : ;;
*) echo "booted target metadata does not record materialized source store" >&2; exit 1 ;;
esac
case "$target_install_metadata" in
*"$install_target_device"*) : ;;
*) echo "booted target metadata does not record install target device" >&2; exit 1 ;;
esac
case "$target_activate_log" in
*fruix-activate:done*) : ;;
*) echo "booted target activation log does not show success" >&2; exit 1 ;;
esac
cat >"$metadata_file" <<EOF
workdir=$workdir
vm_id=$vm_id
primary_vdi_id=$vdi_id
secondary_vdi_id=$secondary_vdi_id
vdi_size=$vdi_size
iso_sr_id=$iso_sr_id
imported_iso_id=$imported_iso_id
imported_iso_name=$imported_iso_name
guest_ip=$guest_ip
target_os_file=$target_os_file
installer_iso_store_path=$iso_store_path
installer_iso_image=$installer_iso_image
installer_boot_efi_image=$installer_boot_efi_image
installer_root_image=$installer_root_image
install_target_device=$install_target_device
installer_root_size=$root_size_out
iso_volume_label=$iso_volume_label
freebsd_source_kind=$freebsd_source_kind_out
freebsd_source_ref=$freebsd_source_ref_out
freebsd_source_commit=$freebsd_source_commit_out
freebsd_source_file=$freebsd_source_file
freebsd_source_materializations_file=$freebsd_source_materializations_file
materialized_source_store_count=$materialized_source_store_count
materialized_source_store=$materialized_source_stores
installer_closure_path=$installer_closure_path
target_closure_path=$target_closure_path
native_base_store_count=$native_base_store_count
native_base_stores=$native_base_stores
store_item_count=$store_item_count
target_store_item_count=$target_store_item_count
installer_store_item_count=$installer_store_item_count
installer_state_path=$installer_state_path
installer_log_path=$installer_log_path
installer_state=$installer_state
installer_run_current_system=$installer_run_current_system
installer_sshd_status=$installer_sshd_status
installer_target_device=$installer_target_device
installer_kern_disks=$installer_kern_disks
installer_log_file=$installer_log_file
installer_gpart_file=$installer_gpart_file
installer_kern_disks_file=$installer_kern_disks_file
target_esp_fstype=$installer_esp_fstype
target_root_fstype=$installer_root_fstype
target_run_current_system=$target_run_current_system
target_shepherd_status=$target_shepherd_status
target_sshd_status=$target_sshd_status
target_install_metadata_file=$target_install_metadata_file
boot_backend=xcp-ng-xo-cli
installer_iso_boot=ok
installer_iso_install=ok
installed_target_boot=ok
EOF
if [ -n "$metadata_target" ]; then
mkdir -p "$(dirname "$metadata_target")"
cp "$metadata_file" "$metadata_target"
fi
printf 'PASS phase18-installer-iso-xcpng\n'
printf 'Work directory: %s\n' "$workdir"
printf 'Metadata file: %s\n' "$metadata_file"
if [ -n "$metadata_target" ]; then
printf 'Copied metadata to: %s\n' "$metadata_target"
fi
printf '%s\n' '--- metadata ---'
cat "$metadata_file"

View File

@@ -0,0 +1,412 @@
#!/bin/sh
set -eu
project_root=${PROJECT_ROOT:-$(pwd)}
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
fruix_cmd=$project_root/bin/fruix
os_template=${OS_TEMPLATE:-$script_dir/phase18-installer-target-operating-system.scm.in}
system_name=${SYSTEM_NAME:-phase18-target-operating-system}
store_dir=${STORE_DIR:-/frx/store}
installer_root_size=${INSTALLER_ROOT_SIZE:-}
target_disk_capacity=${TARGET_DISK_CAPACITY:-12g}
install_target_device=${INSTALL_TARGET_DEVICE:-/dev/vtbd0}
qemu_smp=${QEMU_SMP:-2}
installer_memory=${INSTALLER_MEMORY:-6144}
installer_ssh_port=${INSTALLER_SSH_PORT:-10027}
target_ssh_port=${TARGET_SSH_PORT:-10028}
base_name=${BASE_NAME:-phase18-installer-iso-target}
base_version_label=${BASE_VERSION_LABEL:-15.0-STABLE-installer-iso-target}
base_release=${BASE_RELEASE:-15.0-STABLE}
base_branch=${BASE_BRANCH:-stable/15}
source_name=${SOURCE_NAME:-stable15-installer-iso-target-source}
source_ref=${SOURCE_REF:-stable/15}
source_commit=${SOURCE_COMMIT:-332708a606f6bf0841c1d4a74c0d067f5640fe89}
declared_source_root=${DECLARED_SOURCE_ROOT:-/var/empty/fruix-unused-source-root-installer-iso-target}
metadata_target=${METADATA_OUT:-}
root_authorized_key_file=${ROOT_AUTHORIZED_KEY_FILE:-$HOME/.ssh/id_ed25519.pub}
root_ssh_private_key_file=${ROOT_SSH_PRIVATE_KEY_FILE:-$HOME/.ssh/id_ed25519}
[ -x "$fruix_cmd" ] || {
echo "fruix command is not executable: $fruix_cmd" >&2
exit 1
}
[ -f "$os_template" ] || {
echo "missing operating-system template: $os_template" >&2
exit 1
}
[ -f "$root_authorized_key_file" ] || {
echo "missing root authorized key file: $root_authorized_key_file" >&2
exit 1
}
[ -f "$root_ssh_private_key_file" ] || {
echo "missing root SSH private key file: $root_ssh_private_key_file" >&2
exit 1
}
command -v qemu-system-x86_64 >/dev/null 2>&1 || {
echo "qemu-system-x86_64 is required" >&2
exit 1
}
[ -f /usr/local/share/edk2-qemu/QEMU_UEFI_CODE-x86_64.fd ] || {
echo "missing QEMU UEFI firmware" >&2
exit 1
}
cleanup=0
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-phase18-installer-iso.XXXXXX)
cleanup=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup=0
fi
target_os_file=$workdir/phase18-installer-iso-target-operating-system.scm
installer_out=$workdir/installer-iso.txt
metadata_file=$workdir/phase18-installer-iso-metadata.txt
installer_serial_log=$workdir/installer-iso-serial.log
target_serial_log=$workdir/target-serial.log
installer_qemu_pidfile=$workdir/installer-qemu.pid
target_qemu_pidfile=$workdir/target-qemu.pid
installer_uefi_vars=$workdir/installer-vars.fd
target_uefi_vars=$workdir/target-vars.fd
installer_boot_iso=$workdir/installer-boot.iso
target_image=$workdir/installed-target.img
gpart_log=$workdir/gpart-show.txt
mnt_esp=$workdir/mnt-esp
mnt_root=$workdir/mnt-root
md_unit=
cleanup_workdir() {
if [ -f "$installer_qemu_pidfile" ]; then
sudo kill "$(sudo cat "$installer_qemu_pidfile")" >/dev/null 2>&1 || true
fi
if [ -f "$target_qemu_pidfile" ]; then
sudo kill "$(sudo cat "$target_qemu_pidfile")" >/dev/null 2>&1 || true
fi
if [ -n "$md_unit" ]; then
sudo umount "$mnt_esp" >/dev/null 2>&1 || true
sudo umount "$mnt_root" >/dev/null 2>&1 || true
sudo mdconfig -d -u "$md_unit" >/dev/null 2>&1 || true
fi
if [ "$cleanup" -eq 1 ]; then
rm -rf "$workdir" 2>/dev/null || sudo rm -rf "$workdir"
fi
}
trap cleanup_workdir EXIT INT TERM
root_authorized_key=$(tr -d '\n' < "$root_authorized_key_file")
sed \
-e "s|__BASE_NAME__|$base_name|g" \
-e "s|__BASE_VERSION_LABEL__|$base_version_label|g" \
-e "s|__BASE_RELEASE__|$base_release|g" \
-e "s|__BASE_BRANCH__|$base_branch|g" \
-e "s|__SOURCE_NAME__|$source_name|g" \
-e "s|__SOURCE_REF__|$source_ref|g" \
-e "s|__SOURCE_COMMIT__|$source_commit|g" \
-e "s|__DECLARED_SOURCE_ROOT__|$declared_source_root|g" \
-e "s|__ROOT_AUTHORIZED_KEY__|$root_authorized_key|g" \
"$os_template" > "$target_os_file"
cp /usr/local/share/edk2-qemu/QEMU_UEFI_VARS-x86_64.fd "$installer_uefi_vars"
cp /usr/local/share/edk2-qemu/QEMU_UEFI_VARS-x86_64.fd "$target_uefi_vars"
truncate -s "$target_disk_capacity" "$target_image"
mkdir -p "$mnt_esp" "$mnt_root"
action_env() {
sudo env \
HOME="$HOME" \
GUILE_AUTO_COMPILE=0 \
FRUIX_FREEBSD_BUILD_JOBS="${FRUIX_FREEBSD_BUILD_JOBS:-8}" \
GUIX_SOURCE_DIR="${GUIX_SOURCE_DIR:-$HOME/repos/guix}" \
GUILE_BIN="${GUILE_BIN:-/tmp/guile-freebsd-validate-install/bin/guile}" \
GUILE_EXTRA_PREFIX="${GUILE_EXTRA_PREFIX:-/tmp/guile-gnutls-freebsd-validate-install}" \
SHEPHERD_PREFIX="${SHEPHERD_PREFIX:-/tmp/shepherd-freebsd-validate-install}" \
"$@"
}
if [ -n "$installer_root_size" ]; then
action_env "$fruix_cmd" system installer-iso "$target_os_file" \
--system "$system_name" \
--store "$store_dir" \
--install-target-device "$install_target_device" \
--root-size "$installer_root_size" >"$installer_out"
else
action_env "$fruix_cmd" system installer-iso "$target_os_file" \
--system "$system_name" \
--store "$store_dir" \
--install-target-device "$install_target_device" >"$installer_out"
fi
field() {
sed -n "s/^$1=//p" "$installer_out" | tail -n 1
}
iso_store_path=$(field iso_store_path)
installer_iso_image=$(field iso_image)
installer_boot_efi_image=$(field boot_efi_image)
installer_root_image=$(field root_image)
installer_closure_path=$(field installer_closure_path)
target_closure_path=$(field target_closure_path)
installer_host_name=$(field installer_host_name)
install_target_device_out=$(field install_target_device)
installer_state_path=$(field installer_state_path)
installer_log_path=$(field installer_log_path)
iso_volume_label=$(field iso_volume_label)
root_size_out=$(field root_size)
freebsd_source_kind_out=$(field freebsd_source_kind)
freebsd_source_ref_out=$(field freebsd_source_ref)
freebsd_source_commit_out=$(field freebsd_source_commit)
freebsd_source_file=$(field freebsd_source_file)
freebsd_source_materializations_file=$(field freebsd_source_materializations_file)
materialized_source_store_count=$(field materialized_source_store_count)
materialized_source_stores=$(field materialized_source_stores)
host_base_store_count=$(field host_base_store_count)
native_base_store_count=$(field native_base_store_count)
native_base_stores=$(field native_base_stores)
store_item_count=$(field store_item_count)
target_store_item_count=$(field target_store_item_count)
installer_store_item_count=$(field installer_store_item_count)
store_layout_file=$(field store_layout_file)
[ -d "$iso_store_path" ] || { echo "missing installer ISO store path: $iso_store_path" >&2; exit 1; }
[ -f "$installer_iso_image" ] || { echo "missing installer ISO image: $installer_iso_image" >&2; exit 1; }
[ -f "$installer_boot_efi_image" ] || { echo "missing installer EFI boot image: $installer_boot_efi_image" >&2; exit 1; }
[ -f "$installer_root_image" ] || { echo "missing installer root image: $installer_root_image" >&2; exit 1; }
[ -n "$installer_closure_path" ] || { echo "missing installer closure path" >&2; exit 1; }
[ -n "$target_closure_path" ] || { echo "missing target closure path" >&2; exit 1; }
[ "$install_target_device_out" = "$install_target_device" ] || { echo "unexpected install target device: $install_target_device_out" >&2; exit 1; }
[ "$installer_host_name" = fruix-freebsd-installer ] || { echo "unexpected installer host name: $installer_host_name" >&2; exit 1; }
[ -n "$iso_volume_label" ] || { echo "missing ISO volume label" >&2; exit 1; }
[ "$freebsd_source_kind_out" = git ] || { echo "unexpected source kind: $freebsd_source_kind_out" >&2; exit 1; }
[ "$freebsd_source_ref_out" = "$source_ref" ] || { echo "unexpected source ref: $freebsd_source_ref_out" >&2; exit 1; }
[ "$freebsd_source_commit_out" = "$source_commit" ] || { echo "unexpected source commit: $freebsd_source_commit_out" >&2; exit 1; }
[ "$materialized_source_store_count" = 1 ] || { echo "unexpected materialized source store count: $materialized_source_store_count" >&2; exit 1; }
[ "$host_base_store_count" = 0 ] || { echo "expected zero host base stores, got: $host_base_store_count" >&2; exit 1; }
[ "$native_base_store_count" = 3 ] || { echo "expected three native base stores, got: $native_base_store_count" >&2; exit 1; }
[ -f "$freebsd_source_file" ] || { echo "missing freebsd source file: $freebsd_source_file" >&2; exit 1; }
[ -f "$freebsd_source_materializations_file" ] || { echo "missing source materializations file: $freebsd_source_materializations_file" >&2; exit 1; }
[ -f "$store_layout_file" ] || { echo "missing store layout file: $store_layout_file" >&2; exit 1; }
case "$materialized_source_stores" in
/frx/store/*-freebsd-source-$source_name) : ;;
*) echo "unexpected materialized source store path: $materialized_source_stores" >&2; exit 1 ;;
esac
[ "$store_item_count" -ge "$target_store_item_count" ] || { echo "combined store item count smaller than target store item count" >&2; exit 1; }
[ "$installer_store_item_count" -ge 1 ] || { echo "expected installer store items" >&2; exit 1; }
cp "$installer_iso_image" "$installer_boot_iso"
target_closure_base=$(basename "$target_closure_path")
installer_closure_base=$(basename "$installer_closure_path")
sudo qemu-system-x86_64 \
-machine q35,accel=tcg \
-cpu max \
-m "$installer_memory" \
-smp "$qemu_smp" \
-display none \
-serial "file:$installer_serial_log" \
-monitor none \
-pidfile "$installer_qemu_pidfile" \
-daemonize \
-boot d \
-drive if=pflash,format=raw,readonly=on,file=/usr/local/share/edk2-qemu/QEMU_UEFI_CODE-x86_64.fd \
-drive if=pflash,format=raw,file="$installer_uefi_vars" \
-cdrom "$installer_boot_iso" \
-drive if=virtio,format=raw,file="$target_image" \
-netdev user,id=net0,hostfwd=tcp::${installer_ssh_port}-:22 \
-device virtio-net-pci,netdev=net0
installer_guest() {
ssh -p "$installer_ssh_port" -i "$root_ssh_private_key_file" \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
-o ConnectTimeout=5 \
root@127.0.0.1 "$@"
}
installer_ssh_reached=0
installer_state=missing
for attempt in $(jot 180 1 180); do
if installer_guest 'service sshd onestatus >/dev/null 2>&1' >/dev/null 2>&1; then
installer_ssh_reached=1
installer_state=$(installer_guest "cat '$installer_state_path' 2>/dev/null || echo missing")
[ "$installer_state" = done ] && break
fi
sleep 2
done
[ "$installer_ssh_reached" = 1 ] || { echo "installer ISO environment never became reachable over SSH" >&2; exit 1; }
[ "$installer_state" = done ] || { echo "installer ISO environment did not finish installation: $installer_state" >&2; exit 1; }
installer_run_current_system=$(installer_guest 'readlink /run/current-system')
installer_sshd_status=$(installer_guest 'service sshd onestatus >/dev/null 2>&1 && echo running || echo stopped')
installer_activate_log=$(installer_guest 'cat /var/log/fruix-activate.log 2>/dev/null || true' | tr '\n' ' ')
installer_log=$(installer_guest "cat '$installer_log_path' 2>/dev/null || true" | tr '\n' ' ')
[ "$installer_run_current_system" = "/frx/store/$installer_closure_base" ] || { echo "unexpected installer current-system target: $installer_run_current_system" >&2; exit 1; }
[ "$installer_sshd_status" = running ] || { echo "installer sshd is not running" >&2; exit 1; }
case "$installer_activate_log" in
*fruix-activate:done*) : ;;
*) echo "installer activation log does not show success" >&2; exit 1 ;;
esac
case "$installer_log" in
*fruix-installer:done*) : ;;
*) echo "installer log does not show completion" >&2; exit 1 ;;
esac
sudo kill "$(sudo cat "$installer_qemu_pidfile")" >/dev/null 2>&1 || true
rm -f "$installer_qemu_pidfile"
sleep 2
md=$(sudo mdconfig -a -t vnode -f "$target_image")
md_unit=${md#md}
sudo gpart show -lp "/dev/$md" >"$gpart_log"
esp_fstype=$(sudo fstyp "/dev/${md}p1")
root_fstype=$(sudo fstyp "/dev/${md}p2")
[ "$esp_fstype" = msdosfs ] || { echo "unexpected target ESP filesystem: $esp_fstype" >&2; exit 1; }
[ "$root_fstype" = ufs ] || { echo "unexpected target root filesystem: $root_fstype" >&2; exit 1; }
sudo mount -t msdosfs "/dev/${md}p1" "$mnt_esp"
sudo mount -t ufs -o ro "/dev/${md}p2" "$mnt_root"
[ -f "$mnt_esp/EFI/BOOT/BOOTX64.EFI" ] || { echo "missing EFI boot file on installed target" >&2; exit 1; }
target_run_current_system=$(readlink "$mnt_root/run/current-system")
target_boot_loader=$(readlink "$mnt_root/boot/loader")
install_metadata_host=$(cat "$mnt_root/var/lib/fruix/install.scm")
[ "$target_run_current_system" = "/frx/store/$target_closure_base" ] || { echo "unexpected target /run/current-system target: $target_run_current_system" >&2; exit 1; }
[ "$target_boot_loader" = /run/current-system/boot/loader ] || { echo "unexpected target boot loader link: $target_boot_loader" >&2; exit 1; }
[ -d "$mnt_root/frx/store/$target_closure_base" ] || { echo "installed target closure missing from target root" >&2; exit 1; }
case "$install_metadata_host" in
*"$target_closure_path"*) : ;;
*) echo "installed target metadata does not record target closure path" >&2; exit 1 ;;
esac
case "$install_metadata_host" in
*"$materialized_source_stores"*) : ;;
*) echo "installed target metadata does not record materialized source store" >&2; exit 1 ;;
esac
sudo umount "$mnt_esp"
sudo umount "$mnt_root"
sudo mdconfig -d -u "$md_unit"
md_unit=
sudo qemu-system-x86_64 \
-machine q35,accel=tcg \
-cpu max \
-m 2048 \
-smp "$qemu_smp" \
-display none \
-serial "file:$target_serial_log" \
-monitor none \
-pidfile "$target_qemu_pidfile" \
-daemonize \
-drive if=pflash,format=raw,readonly=on,file=/usr/local/share/edk2-qemu/QEMU_UEFI_CODE-x86_64.fd \
-drive if=pflash,format=raw,file="$target_uefi_vars" \
-drive if=virtio,format=raw,file="$target_image" \
-netdev user,id=net0,hostfwd=tcp::${target_ssh_port}-:22 \
-device virtio-net-pci,netdev=net0
target_guest() {
ssh -p "$target_ssh_port" -i "$root_ssh_private_key_file" \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
-o ConnectTimeout=5 \
root@127.0.0.1 "$@"
}
for attempt in $(jot 120 1 120); do
if target_guest 'service sshd onestatus >/dev/null 2>&1' >/dev/null 2>&1; then
break
fi
sleep 2
done
target_run_current_system_guest=$(target_guest 'readlink /run/current-system')
target_shepherd_status=$(target_guest '/usr/local/etc/rc.d/fruix-shepherd onestatus >/dev/null 2>&1 && echo running || echo stopped')
target_sshd_status=$(target_guest 'service sshd onestatus >/dev/null 2>&1 && echo running || echo stopped')
target_install_metadata_guest=$(target_guest 'cat /var/lib/fruix/install.scm')
target_activate_log=$(target_guest 'cat /var/log/fruix-activate.log 2>/dev/null || true' | tr '\n' ' ')
[ "$target_run_current_system_guest" = "/frx/store/$target_closure_base" ] || { echo "unexpected booted target current-system: $target_run_current_system_guest" >&2; exit 1; }
[ "$target_shepherd_status" = running ] || { echo "fruix-shepherd is not running in booted target" >&2; exit 1; }
[ "$target_sshd_status" = running ] || { echo "sshd is not running in booted target" >&2; exit 1; }
case "$target_install_metadata_guest" in
*"$target_closure_path"*) : ;;
*) echo "booted target metadata does not record target closure path" >&2; exit 1 ;;
esac
case "$target_install_metadata_guest" in
*"$materialized_source_stores"*) : ;;
*) echo "booted target metadata does not record materialized source store" >&2; exit 1 ;;
esac
case "$target_activate_log" in
*fruix-activate:done*) : ;;
*) echo "booted target activation log does not show success" >&2; exit 1 ;;
esac
cat >"$metadata_file" <<EOF
workdir=$workdir
target_os_file=$target_os_file
installer_iso_store_path=$iso_store_path
installer_iso_image=$installer_iso_image
installer_boot_iso=$installer_boot_iso
installer_boot_efi_image=$installer_boot_efi_image
installer_root_image=$installer_root_image
installer_memory=$installer_memory
installer_root_size=$root_size_out
target_image=$target_image
target_disk_capacity=$target_disk_capacity
install_target_device=$install_target_device
qemu_smp=$qemu_smp
iso_volume_label=$iso_volume_label
freebsd_source_kind=$freebsd_source_kind_out
freebsd_source_ref=$freebsd_source_ref_out
freebsd_source_commit=$freebsd_source_commit_out
freebsd_source_file=$freebsd_source_file
freebsd_source_materializations_file=$freebsd_source_materializations_file
materialized_source_store_count=$materialized_source_store_count
materialized_source_store=$materialized_source_stores
installer_closure_path=$installer_closure_path
target_closure_path=$target_closure_path
native_base_store_count=$native_base_store_count
native_base_stores=$native_base_stores
store_item_count=$store_item_count
target_store_item_count=$target_store_item_count
installer_store_item_count=$installer_store_item_count
installer_state_path=$installer_state_path
installer_log_path=$installer_log_path
installer_state=$installer_state
installer_run_current_system=$installer_run_current_system
installer_sshd_status=$installer_sshd_status
installer_serial_log=$installer_serial_log
target_esp_fstype=$esp_fstype
target_root_fstype=$root_fstype
gpart_log=$gpart_log
target_run_current_system=$target_run_current_system_guest
target_shepherd_status=$target_shepherd_status
target_sshd_status=$target_sshd_status
target_serial_log=$target_serial_log
installer_iso_boot=ok
installer_iso_install=ok
installed_target_boot=ok
EOF
if [ -n "$metadata_target" ]; then
mkdir -p "$(dirname "$metadata_target")"
cp "$metadata_file" "$metadata_target"
fi
printf 'PASS phase18-installer-iso\n'
printf 'Work directory: %s\n' "$workdir"
printf 'Metadata file: %s\n' "$metadata_file"
if [ -n "$metadata_target" ]; then
printf 'Copied metadata to: %s\n' "$metadata_target"
fi
printf '%s\n' '--- metadata ---'
cat "$metadata_file"

View File

@@ -0,0 +1,160 @@
#!/bin/sh
set -eu
repo_root=${PROJECT_ROOT:-$(pwd)}
metadata_target=${METADATA_OUT:-}
cleanup=0
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-phase19-generation-layout-qemu.XXXXXX)
cleanup=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup=0
fi
inner_workdir=$workdir/phase18-install
inner_metadata=$workdir/phase18-system-install-metadata.txt
metadata_file=$workdir/phase19-generation-layout-qemu-metadata.txt
mnt_esp=$workdir/mnt-esp
mnt_root=$workdir/mnt-root
md_unit=
cleanup_workdir() {
if [ -n "$md_unit" ]; then
sudo umount "$mnt_esp" >/dev/null 2>&1 || true
sudo umount "$mnt_root" >/dev/null 2>&1 || true
sudo mdconfig -d -u "$md_unit" >/dev/null 2>&1 || true
fi
if [ "$cleanup" -eq 1 ]; then
rm -rf "$workdir" 2>/dev/null || sudo rm -rf "$workdir"
fi
}
trap cleanup_workdir EXIT INT TERM
mkdir -p "$mnt_esp" "$mnt_root"
KEEP_WORKDIR=1 WORKDIR="$inner_workdir" METADATA_OUT="$inner_metadata" \
"$repo_root/tests/system/run-phase18-system-install.sh" >/dev/null
field() {
sed -n "s/^$1=//p" "$inner_metadata" | tail -n 1
}
target_image=$(field target_image)
closure_path=$(field closure_path)
materialized_source_store=$(field materialized_source_store)
install_metadata_path=$(field install_metadata_path)
run_current_system_target_reported=$(field run_current_system_target)
serial_log=$(field serial_log)
closure_base=$(basename "$closure_path")
guest_md=$(sudo mdconfig -a -t vnode -f "$target_image")
md_unit=${guest_md#md}
sudo mount -t msdosfs "/dev/${guest_md}p1" "$mnt_esp"
sudo mount -t ufs -o ro "/dev/${guest_md}p2" "$mnt_root"
system_root=$mnt_root/var/lib/fruix/system
generation_root=$system_root/generations/1
gcroots_root=$mnt_root/frx/var/fruix/gcroots
[ -d "$system_root" ] || { echo "missing explicit system generation root" >&2; exit 1; }
[ -d "$generation_root" ] || { echo "missing generation 1 directory" >&2; exit 1; }
[ -f "$system_root/current-generation" ] || { echo "missing current-generation file" >&2; exit 1; }
[ -L "$system_root/current" ] || { echo "missing current generation link" >&2; exit 1; }
[ -L "$generation_root/closure" ] || { echo "missing generation closure link" >&2; exit 1; }
[ -f "$generation_root/metadata.scm" ] || { echo "missing generation metadata file" >&2; exit 1; }
[ -f "$generation_root/provenance.scm" ] || { echo "missing generation provenance file" >&2; exit 1; }
[ -f "$generation_root/install.scm" ] || { echo "missing generation install metadata file" >&2; exit 1; }
[ -L "$gcroots_root/current-system" ] || { echo "missing current-system gc root" >&2; exit 1; }
[ -L "$gcroots_root/system-1" ] || { echo "missing system-1 gc root" >&2; exit 1; }
current_generation=$(tr -d '\n' < "$system_root/current-generation")
current_link=$(readlink "$system_root/current")
generation_closure=$(readlink "$generation_root/closure")
gcroot_current=$(readlink "$gcroots_root/current-system")
gcroot_generation=$(readlink "$gcroots_root/system-1")
run_current_system_target=$(readlink "$mnt_root/run/current-system")
generation_metadata=$(cat "$generation_root/metadata.scm")
generation_provenance=$(cat "$generation_root/provenance.scm")
generation_install=$(cat "$generation_root/install.scm")
install_metadata_host=$(cat "$mnt_root$install_metadata_path")
[ "$current_generation" = 1 ] || { echo "unexpected current generation: $current_generation" >&2; exit 1; }
[ "$current_link" = generations/1 ] || { echo "unexpected current link target: $current_link" >&2; exit 1; }
[ "$generation_closure" = "/frx/store/$closure_base" ] || { echo "unexpected generation closure target: $generation_closure" >&2; exit 1; }
[ "$gcroot_current" = "/frx/store/$closure_base" ] || { echo "unexpected current-system gc root: $gcroot_current" >&2; exit 1; }
[ "$gcroot_generation" = "/frx/store/$closure_base" ] || { echo "unexpected system-1 gc root: $gcroot_generation" >&2; exit 1; }
[ "$run_current_system_target" = "/frx/store/$closure_base" ] || { echo "unexpected /run/current-system target: $run_current_system_target" >&2; exit 1; }
[ "$run_current_system_target_reported" = "/frx/store/$closure_base" ] || { echo "unexpected reported guest current-system target: $run_current_system_target_reported" >&2; exit 1; }
case "$generation_metadata" in
*"$closure_path"*) : ;;
*) echo "generation metadata does not record closure path" >&2; exit 1 ;;
esac
case "$generation_metadata" in
*"$install_metadata_path"*) : ;;
*) echo "generation metadata does not record install metadata path" >&2; exit 1 ;;
esac
case "$generation_provenance" in
*"$closure_path/metadata/store-layout.scm"*) : ;;
*) echo "generation provenance does not reference store layout metadata" >&2; exit 1 ;;
esac
case "$generation_install" in
*"$closure_path"*) : ;;
*) echo "generation install metadata does not record closure path" >&2; exit 1 ;;
esac
case "$generation_install" in
*"$materialized_source_store"*) : ;;
*) echo "generation install metadata does not record materialized source store" >&2; exit 1 ;;
esac
case "$install_metadata_host" in
*"$closure_path"*) : ;;
*) echo "installed target metadata does not record closure path" >&2; exit 1 ;;
esac
sudo umount "$mnt_esp"
sudo umount "$mnt_root"
sudo mdconfig -d -u "$md_unit"
md_unit=
cat >"$metadata_file" <<EOF
workdir=$workdir
inner_workdir=$inner_workdir
inner_metadata=$inner_metadata
target_image=$target_image
closure_path=$closure_path
closure_base=$closure_base
materialized_source_store=$materialized_source_store
install_metadata_path=$install_metadata_path
system_root=$system_root
generation_root=$generation_root
gcroots_root=$gcroots_root
current_generation=$current_generation
current_link=$current_link
generation_closure=$generation_closure
gcroot_current=$gcroot_current
gcroot_generation=$gcroot_generation
run_current_system_target=$run_current_system_target
serial_log=$serial_log
generation_layout=explicit
boot_backend=qemu-uefi-tcg
generation_layout_validation=ok
EOF
if [ -n "$metadata_target" ]; then
mkdir -p "$(dirname "$metadata_target")"
cp "$metadata_file" "$metadata_target"
fi
printf 'PASS phase19-generation-layout-qemu\n'
printf 'Work directory: %s\n' "$workdir"
printf 'Metadata file: %s\n' "$metadata_file"
if [ -n "$metadata_target" ]; then
printf 'Copied metadata to: %s\n' "$metadata_target"
fi
printf '%s\n' '--- metadata ---'
cat "$metadata_file"