system: validate installed rollback workflow

This commit is contained in:
2026-04-05 01:39:24 +02:00
parent b3b1ba2489
commit 9dae4e5c84
9 changed files with 1170 additions and 75 deletions

View File

@@ -48,13 +48,12 @@ That path remains the active runtime boundary used by activation and service wir
Fruix avoids in-place mutation of an older deployed closure. Fruix avoids in-place mutation of an older deployed closure.
The validated rollback story today is: The validated rollback story now has two layers:
- keep the earlier declaration - declaration-level rollback by rebuilding/redeploying an earlier declaration
- rebuild or rematerialize it - installed-system rollback between already-recorded generations on the target itself
- 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. That is Guix-like in spirit, although Fruix still exposes a smaller installed-system workflow than Guix's more mature `reconfigure` model.
### Generation-style metadata and roots ### Generation-style metadata and roots
@@ -78,7 +77,7 @@ Guix heavily reuses its profile-generation model and represents a lot of meaning
Fruix keeps the **semantics** but uses a more explicit metadata-oriented layout for installed systems. Fruix keeps the **semantics** but uses a more explicit metadata-oriented layout for installed systems.
Current Fruix layout: Current Fruix layout starts as:
```text ```text
/var/lib/fruix/system/ /var/lib/fruix/system/
@@ -92,6 +91,20 @@ Current Fruix layout:
install.scm install.scm
``` ```
After a validated installed-system switch, Fruix also records:
```text
/var/lib/fruix/system/
rollback -> generations/1
rollback-generation
generations/
2/
closure -> /frx/store/...-fruix-system-...
metadata.scm
provenance.scm
install.scm
```
Why Fruix does this: Why Fruix does this:
- it makes deployment state easier to inspect directly - it makes deployment state easier to inspect directly
@@ -154,27 +167,35 @@ Validated examples:
So compared with Guix-on-Linux intuition, Fruix operators should be more explicit about target-device selection during installation and installer-media validation. 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 ## 6. Fruix now has a minimal installed-system generation command surface, but it is still smaller than Guix's
This is the biggest current operational gap. This remains the biggest operational gap, but it is no longer a complete gap.
Fruix does **not** yet provide a mature equivalent of the familiar Guix System operator flow around in-place generation switching and rollback commands. Installed Fruix systems now provide a small in-guest helper:
Today, Fruix rollback is mostly: - `fruix system status`
- `fruix system switch /frx/store/...-fruix-system-...`
- `fruix system rollback`
- declaration-driven What this gives you today:
- rebuild/redeploy based
rather than: - explicit current-generation tracking
- explicit rollback-generation tracking
- in-place switching between already-staged closures on the installed target
- rollback without reinstalling the whole system image again
- switch current system generation in place through a dedicated command What it still does **not** give you yet compared with Guix:
So if you come from Guix, assume that Fruix currently has: - a mature `reconfigure`-style workflow that builds and stages the new closure from inside the target system
- automatic closure transfer/fetch as part of `switch`
- the broader generation-management UX Guix operators expect
So if you come from Guix, assume that Fruix now has:
- strong closure/store semantics - strong closure/store semantics
- explicit install artifacts - explicit install artifacts
- explicit generation metadata roots - explicit generation metadata roots
- but a less mature installed-system generation UX - a real but still modest installed-system switch/rollback UX
## Where Fruix is intentionally trying to improve on Guix's representation ## Where Fruix is intentionally trying to improve on Guix's representation
@@ -202,10 +223,11 @@ If you are already comfortable with Guix, the safest Fruix mental model today is
- closure path - closure path
- source provenance metadata - source provenance metadata
- install metadata - install metadata
5. think of rollback today as: 5. think of rollback in two layers:
- “redeploy the earlier declaration again” - if the target already has the desired closure staged locally:
rather than: - use `fruix system rollback`
- “switch to an already-managed previous generation in place” - otherwise:
- redeploy the earlier declaration again
## Status summary ## Status summary
@@ -222,4 +244,4 @@ It differs most from Guix in:
- source-provenance emphasis - source-provenance emphasis
- installer-medium-oriented workflows - installer-medium-oriented workflows
- generation-layout representation - generation-layout representation
- and the still-maturing installed-system generation command surface - and an installed-system generation command surface that now exists, but is still much smaller than Guix's

View File

@@ -30,6 +30,10 @@ Fruix currently has:
- `/var/lib/fruix/system` - `/var/lib/fruix/system`
- explicit installed-system retention roots under: - explicit installed-system retention roots under:
- `/frx/var/fruix/gcroots` - `/frx/var/fruix/gcroots`
- a validated installed-system generation switch/rollback workflow via:
- `fruix system status`
- `fruix system switch`
- `fruix system rollback`
Validated boot modes still are: Validated boot modes still are:
@@ -42,30 +46,35 @@ The validated Phase 18 installation work currently uses:
## Latest completed achievement ## Latest completed achievement
### 2026-04-04 — Phase 19.2 completed ### 2026-04-04 — Phase 19.3 completed
Fruix now records an explicit installed-system generation layout and retention-root model instead of relying mainly on harness knowledge. Fruix now has a validated installed-system operator workflow for switching to a staged candidate generation and rolling back to the recorded previous generation.
Highlights: Highlights:
- added explicit installed-system generation layout under: - installed systems now ship an in-guest Fruix deployment helper at:
- `/var/lib/fruix/system` - `/usr/local/bin/fruix`
- added explicit installed-system retention roots under: - validated in-guest command surface:
- `/frx/var/fruix/gcroots` - `fruix system status`
- installed targets now record a first-generation deployment directory containing: - `fruix system switch /frx/store/...-fruix-system-...`
- `closure` - `fruix system rollback`
- `metadata.scm` - switching now records explicit rollback state under:
- `provenance.scm` - `/var/lib/fruix/system/rollback`
- `install.scm` - `/var/lib/fruix/system/rollback-generation`
- `/run/current-system` remains the runtime boundary and still points directly at the active closure path - switching now records explicit rollback GC roots under:
- added Guix-oriented operator notes in: - `/frx/var/fruix/gcroots/rollback-system`
- `docs/GUIX_DIFFERENCES.md` - the validated installed-system workflow now supports:
- updated deployment workflow documentation to reflect the new explicit generation model - stage candidate closure in `/frx/store`
- switch to generation 2
- reboot into the candidate
- rollback to generation 1
- reboot into the restored current system
Validation: Validation:
- `PASS phase19-generation-layout-qemu` - `PASS phase19-installed-system-rollback-qemu`
- regression re-check: - regression re-checks:
- `PASS phase19-generation-layout-qemu`
- `PASS phase18-installer-iso` - `PASS phase18-installer-iso`
Reports: Reports:
@@ -74,6 +83,7 @@ Reports:
- `docs/GUIX_DIFFERENCES.md` - `docs/GUIX_DIFFERENCES.md`
- `docs/reports/phase19-deployment-workflow-freebsd.md` - `docs/reports/phase19-deployment-workflow-freebsd.md`
- `docs/reports/phase19-generation-layout-freebsd.md` - `docs/reports/phase19-generation-layout-freebsd.md`
- `docs/reports/phase19-installed-system-rollback-freebsd.md`
## Recent major milestones ## Recent major milestones
@@ -99,6 +109,6 @@ Reports:
Per `docs/PLAN_4.md`, the next planned step is: Per `docs/PLAN_4.md`, the next planned step is:
- **Phase 19.3** — validate installed-system rollback through the intended operator-facing workflow - **Phase 20.1** — validate a Fruix-managed development environment for native FreeBSD base work
Phase 19.2 is now complete: Fruix has an explicit installed-system generation layout and retention-root model on FreeBSD. Phase 19.3 is now complete: Fruix validates installed-system generation switching and rollback through the intended operator-facing workflow.

View File

@@ -0,0 +1,226 @@
# Phase 19.3: installed-system rollback workflow on FreeBSD
Date: 2026-04-04
## Goal
Phase 19.3 is about validating installed-system rollback through the intended operator-facing workflow, not only through host-side build/image redeploy harnesses.
The key question was:
- can an already-installed Fruix system move between recorded generations coherently, using an operator-facing command surface on the target itself?
## Decision
The current Fruix solution is intentionally modest.
Fruix now provides a small installed-system helper on the target itself:
- `/usr/local/bin/fruix`
Validated in-guest commands:
- `fruix system status`
- `fruix system switch /frx/store/...-fruix-system-...`
- `fruix system rollback`
Important scope choice:
- `switch` assumes the candidate closure is already present on the target's `/frx/store`
- Fruix does **not** yet fetch or transfer that closure onto the target automatically
That keeps Phase 19.3 focused on generation-state correctness rather than introducing a larger store-transfer story prematurely.
## Implemented model
Installed systems now support the following validated operator pattern:
1. build a candidate closure with the host-side Fruix frontend
2. stage that closure into the installed system's `/frx/store`
3. run:
- `fruix system switch /frx/store/...candidate...`
4. reboot into the candidate generation
5. if needed, run:
- `fruix system rollback`
6. reboot into the recorded rollback generation
The installed system now records explicit rollback state under:
```text
/var/lib/fruix/system/
current -> generations/N
current-generation
rollback -> generations/M
rollback-generation
generations/
...
```
and explicit rollback reachability under:
```text
/frx/var/fruix/gcroots/
current-system -> /frx/store/...current...
rollback-system -> /frx/store/...rollback...
system-1 -> ...
system-2 -> ...
```
## Code changes
### `modules/fruix/system/freebsd/render.scm`
Added a generated in-guest Fruix deployment helper script under:
- `usr/local/bin/fruix`
That helper now:
- reports installed-system state with `fruix system status`
- stages a new current generation with `fruix system switch`
- stages the recorded rollback generation with `fruix system rollback`
- updates:
- `/var/lib/fruix/system/current`
- `/var/lib/fruix/system/current-generation`
- `/var/lib/fruix/system/rollback`
- `/var/lib/fruix/system/rollback-generation`
- `/frx/var/fruix/gcroots/current-system`
- `/frx/var/fruix/gcroots/rollback-system`
- `/frx/var/fruix/gcroots/system-N`
- refreshes the ESP bootloader file from the selected closure's `boot/loader.efi`
A practical implementation detail mattered here:
- replacing `/run/current-system` with a remove-then-recreate strategy caused the live shell environment to break while the link was absent
- switching that update to an atomic symlink replacement path for `/run/current-system` avoided that gap and made the in-guest operator command reliable
### `modules/fruix/system/freebsd/media.scm`
Updated installed rootfs staging so that installed targets expose:
- `/usr/local/bin/fruix -> /run/current-system/usr/local/bin/fruix`
Also bumped the explicit generation-layout version from:
- `1` to `2`
because the installed-system model now includes operator-driven switch/rollback state as part of the validated layout story.
### `modules/fruix/system/freebsd/model.scm`
Updated generated-file metadata so the system closure records:
- `usr/local/bin/fruix`
as part of the generated operating-system file set.
## New validation harness
Added:
- `tests/system/run-phase19-installed-system-rollback-qemu.sh`
This harness validates the actual installed-system operator flow on local `QEMU/UEFI/TCG`.
## Validation flow
The harness now performs all of the following:
1. installs a current system image directly to a target disk image
2. builds a distinct candidate closure
- in the validated harness this differs by host name so the closure identity changes cleanly without needing a heavier base-version rebuild
3. stages the candidate closure and its referenced store items into the installed target's `/frx/store`
4. boots the installed current system
5. validates initial state:
- current generation = `1`
- current closure = current installed closure
- no rollback generation yet recorded
6. runs:
- `fruix system switch /frx/store/...candidate...`
7. validates staged switch state:
- current generation = `2`
- rollback generation = `1`
- current closure = candidate closure
- rollback closure = original current closure
- generation 2 metadata/install files were written
8. reboots and validates boot into the candidate closure
9. runs:
- `fruix system rollback`
10. validates staged rollback state:
- current generation = `1`
- rollback generation = `2`
- current closure = original closure
- rollback closure = candidate closure
11. reboots and validates boot back into the original current system
12. confirms post-rollback service state:
- `fruix-shepherd` running
- `sshd` running
- activation log still shows success
## Passing validation
Passing result:
- `PASS phase19-installed-system-rollback-qemu`
Validated metadata summary:
```text
current_closure_path=/frx/store/4debd106d62f14594ba1612e1e7105f1658bf5f4075d6e5db5436efeaf929d90-fruix-system-fruix-freebsd-current
candidate_closure_path=/frx/store/54fb14e6071b8e5704a5dc75e2881c2f0533767771c26c4181f57afea88d1e8b-fruix-system-fruix-freebsd-canary
current_host_name=fruix-freebsd-current
candidate_host_name=fruix-freebsd-canary
final_current_generation=1
final_current_closure=/frx/store/4debd106d62f14594ba1612e1e7105f1658bf5f4075d6e5db5436efeaf929d90-fruix-system-fruix-freebsd-current
final_rollback_generation=2
final_rollback_closure=/frx/store/54fb14e6071b8e5704a5dc75e2881c2f0533767771c26c4181f57afea88d1e8b-fruix-system-fruix-freebsd-canary
installed_system_switch=ok
installed_system_rollback=ok
```
## Regression checks
After landing the installed-system switch/rollback workflow, the following regression checks still pass:
- `PASS phase19-generation-layout-qemu`
- `PASS phase18-installer-iso`
That means the new in-guest generation-management path did not regress:
- the previously validated explicit generation layout
- or the UEFI installer ISO boot/install path
## Relationship to Guix
This phase does **not** claim that Fruix now matches Guix's full installed-system UX.
What Fruix now has is:
- explicit generation state on disk
- explicit current/rollback pointers
- a minimal installed-system operator command surface
- validated switching and rollback between already-staged closures
What still remains compared with Guix:
- building/staging the candidate closure from inside the target system itself
- automatic closure transfer/fetch as part of `switch`
- a richer long-term generation lifecycle policy
## Conclusion
Phase 19.3 is complete.
Fruix now validates an actual installed-system rollback workflow on FreeBSD:
- the target system itself can report current/rollback state
- it can switch to a staged candidate generation
- it can reboot into that candidate generation
- it can roll back to the recorded prior generation
- and it can reboot into the restored current system
That closes the Phase 19 deployment story from:
- documented deployment workflow
- to explicit generation layout
- to validated installed-system operator rollback behavior

View File

@@ -11,9 +11,10 @@ This document defines the current canonical Fruix workflow for:
- installing a declarative system onto an image or disk - installing a declarative system onto an image or disk
- booting through installer media - booting through installer media
- rolling forward to a candidate system - rolling forward to a candidate system
- rolling back to an earlier declared system - switching an installed system to a staged candidate generation
- rolling an installed system back to an earlier recorded generation
This is the Phase 19.1 operator-facing view of the system model already implemented in earlier phases. This is now the Phase 19 operator-facing view of the system model as validated through explicit installed-system generation switching and rollback.
## Core model ## Core model
@@ -168,6 +169,34 @@ Use this when you want to:
- install from an ISO-attached Fruix environment - install from an ISO-attached Fruix environment
- test the same install model on more realistic VM paths - test the same install model on more realistic VM paths
### Installed-system generation commands
Installed Fruix systems now also ship a small in-guest deployment helper at:
- `/usr/local/bin/fruix`
Current validated in-guest commands are:
```sh
fruix system status
fruix system switch /frx/store/...-fruix-system-...
fruix system rollback
```
Current intended usage:
1. build a candidate closure on the operator side with `./bin/fruix system build`
2. ensure that candidate closure is present on the installed target's `/frx/store`
3. run `fruix system switch /frx/store/...` on the installed system
4. reboot into the staged candidate generation
5. if needed, run `fruix system rollback`
6. reboot back into the recorded rollback generation
Important current limitation:
- `fruix system switch` does **not** yet fetch or copy the candidate closure onto the target for you
- it assumes the selected closure is already present in the installed system's `/frx/store`
## Deployment patterns ## Deployment patterns
### 1. Build-first workflow ### 1. Build-first workflow
@@ -261,7 +290,7 @@ Installed Fruix systems now record an explicit first-generation deployment layou
- `/var/lib/fruix/system` - `/var/lib/fruix/system`
Current validated shape: Initial installed shape:
```text ```text
/var/lib/fruix/system/ /var/lib/fruix/system/
@@ -275,6 +304,24 @@ Current validated shape:
install.scm # present on installed targets install.scm # present on installed targets
``` ```
After a validated in-place switch, the layout extends to:
```text
/var/lib/fruix/system/
current -> generations/2
current-generation
rollback -> generations/1
rollback-generation
generations/
1/
...
2/
closure -> /frx/store/...-fruix-system-...
metadata.scm
provenance.scm
install.scm # deployment metadata for the switch operation
```
Installed systems also now create explicit GC-root-style deployment links under: Installed systems also now create explicit GC-root-style deployment links under:
- `/frx/var/fruix/gcroots` - `/frx/var/fruix/gcroots`
@@ -284,7 +331,9 @@ Current validated shape:
```text ```text
/frx/var/fruix/gcroots/ /frx/var/fruix/gcroots/
current-system -> /frx/store/...-fruix-system-... current-system -> /frx/store/...-fruix-system-...
rollback-system -> /frx/store/...-fruix-system-...
system-1 -> /frx/store/...-fruix-system-... system-1 -> /frx/store/...-fruix-system-...
system-2 -> /frx/store/...-fruix-system-...
``` ```
Important detail: Important detail:
@@ -294,7 +343,9 @@ Important detail:
## Roll-forward workflow ## Roll-forward workflow
The current Fruix roll-forward model is declaration-driven. The current Fruix roll-forward model now has two validated layers.
### Declaration/deployment roll-forward
Canonical process: Canonical process:
@@ -314,47 +365,61 @@ Canonical process:
5. boot or install the candidate 5. boot or install the candidate
6. validate the candidate closure in the booted system 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. ### Installed-system generation roll-forward
When the candidate closure is already present on an installed target:
1. run `fruix system switch /frx/store/...candidate...`
2. confirm the staged state with `fruix system status`
3. reboot into the candidate generation
4. validate the new active closure after reboot
The important property is still that the candidate closure appears beside the earlier one in `/frx/store` rather than mutating it in place.
## Rollback workflow ## Rollback workflow
The current canonical rollback workflow is also declaration-driven. The current canonical rollback workflow also now has two validated layers.
Today, rollback means: ### Declaration/deployment rollback
You can still roll back by redeploying the earlier declaration:
1. retain the earlier declaration that produced the known-good closure 1. retain the earlier declaration that produced the known-good closure
2. rebuild or rematerialize that earlier declaration 2. rebuild or rematerialize that earlier declaration
3. redeploy or reboot that earlier artifact again 3. redeploy or reboot that earlier artifact again
Concretely, the usual rollback choices are: Concretely, the usual declaration-level rollback choices are:
- rebuild the earlier declaration with `fruix system build` and confirm the old closure path reappears - rebuild the earlier declaration with `fruix system build` and confirm the old closure path reappears
- boot the earlier declaration again through `fruix system image` - 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 - 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: ### Installed-system generation rollback
- side-by-side base-version coexistence in `/frx/store` When an installed target already has both the current and rollback generations recorded:
- roll-forward to a candidate closure
- rollback by rebuilding and booting the earlier declaration again 1. run `fruix system rollback`
- validation on both local QEMU and the approved XCP-ng VM path 2. confirm the staged state with `fruix system status`
3. reboot into the rollback generation
4. validate the restored active closure after reboot
This installed-system rollback path is now validated on local `QEMU/UEFI/TCG`.
### Important scope note ### Important scope note
This is not yet the same thing as a first-class installed-system generation switch command. This is still not yet the same thing as Guix's full `reconfigure`/generation UX.
Current rollback is: Current installed-system rollback is intentionally modest:
- **redeploy the earlier declaration again** - it switches between already-recorded generations on the target
- it does not yet fetch candidate closures onto the machine for you
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. - it does not yet expose a richer history-management or generation-pruning policy
Still pending: Still pending:
- previous-generation tracking beyond the initial explicit generation-1 layout - operator-facing closure transfer or fetch onto installed systems
- an explicit rollback target link distinct from `current` - multi-generation lifecycle policy beyond the validated `current` and `rollback` pointers
- an operator-facing installed-system rollback workflow - a fuller `reconfigure`-style installed-system UX
- generation switching without full redeploy
## Provenance and deployment identity ## Provenance and deployment identity
@@ -375,16 +440,16 @@ Operators should retain metadata from successful candidate and current deploymen
## Current limitations ## Current limitations
The deployment workflow is now coherent, but it is not yet the final generation-management story. The deployment workflow is now coherent, and Fruix now has a validated installed-system switch/rollback path, but it is still not the final generation-management story.
Not yet first-class: Not yet first-class:
- a dedicated `switch` or `reconfigure` command - host-side closure transfer/fetch onto installed systems as part of `fruix system switch`
- an installed-system rollback command that moves between generations in place - a fuller `reconfigure` workflow that builds and stages the new closure from inside the target environment
- multi-generation retention and previous-generation tracking beyond generation 1 - multi-generation lifecycle policy beyond the validated `current` and `rollback` pointers
- generation switching policy independent of full redeploy - generation pruning and retention policy independent of full redeploy
Those are the next logical steps after the current explicit-generation layout. Those are the next logical steps after the current explicit-generation switch/rollback model.
## Summary ## Summary
@@ -395,6 +460,8 @@ The current canonical Fruix deployment model is:
- **materialize** the artifact appropriate to the deployment target - **materialize** the artifact appropriate to the deployment target
- **boot or install** that artifact - **boot or install** that artifact
- **identify deployments by closure path and provenance metadata** - **identify deployments by closure path and provenance metadata**
- **roll back by rebuilding/redeploying the earlier declaration**, not by mutating the current closure in place - on installed systems, **switch** to a staged candidate with `fruix system switch`
- on installed systems, **roll back** to the recorded rollback generation with `fruix system rollback`
- still use declaration/redeploy rollback when the target does not already have the desired closure staged locally
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. That is the operator-facing workflow Fruix should document and use while its installed-system generation UX remains simpler than Guix's mature in-place system-generation workflow.

View File

@@ -201,6 +201,8 @@
(chmod (string-append closure-path "/etc/master.passwd") #o600)) (chmod (string-append closure-path "/etc/master.passwd") #o600))
(chmod (string-append closure-path "/usr/local/etc/rc.d/fruix-activate") #o555) (chmod (string-append closure-path "/usr/local/etc/rc.d/fruix-activate") #o555)
(chmod (string-append closure-path "/usr/local/etc/rc.d/fruix-shepherd") #o555) (chmod (string-append closure-path "/usr/local/etc/rc.d/fruix-shepherd") #o555)
(when (file-exists? (string-append closure-path "/usr/local/bin/fruix"))
(chmod (string-append closure-path "/usr/local/bin/fruix") #o555))
(when (file-exists? (string-append closure-path "/boot/fruix-pid1")) (when (file-exists? (string-append closure-path "/boot/fruix-pid1"))
(chmod (string-append closure-path "/boot/fruix-pid1") #o555)) (chmod (string-append closure-path "/boot/fruix-pid1") #o555))
(write-file (string-append closure-path "/parameters.scm") (write-file (string-append closure-path "/parameters.scm")
@@ -233,7 +235,7 @@
(mkdir-p (dirname link-name)) (mkdir-p (dirname link-name))
(symlink target link-name)) (symlink target link-name))
(define system-generation-layout-version "1") (define system-generation-layout-version "2")
(define* (system-generation-metadata-object os closure-path (define* (system-generation-metadata-object os closure-path
#:key #:key
@@ -308,8 +310,9 @@
(mkdir-p rootfs) (mkdir-p rootfs)
(for-each (lambda (dir) (for-each (lambda (dir)
(mkdir-p (string-append rootfs dir))) (mkdir-p (string-append rootfs dir)))
'("/run" "/boot" "/etc" "/etc/ssh" "/usr" "/usr/share" "/usr/local" "/usr/local/etc" '("/run" "/boot" "/etc" "/etc/ssh" "/usr" "/usr/share" "/usr/local"
"/usr/local/etc/rc.d" "/var" "/var/cron" "/var/db" "/var/lib" "/var/lib/fruix" "/usr/local/bin" "/usr/local/etc" "/usr/local/etc/rc.d" "/var"
"/var/cron" "/var/db" "/var/lib" "/var/lib/fruix"
"/var/log" "/var/run" "/tmp" "/dev" "/root" "/home")) "/var/log" "/var/run" "/tmp" "/dev" "/root" "/home"))
(chmod (string-append rootfs "/tmp") #o1777) (chmod (string-append rootfs "/tmp") #o1777)
(symlink-force closure-path (string-append rootfs "/run/current-system")) (symlink-force closure-path (string-append rootfs "/run/current-system"))
@@ -345,6 +348,8 @@
(symlink-force (string-append "/run/current-system/boot/" path) (symlink-force (string-append "/run/current-system/boot/" path)
(string-append rootfs "/boot/" path))) (string-append rootfs "/boot/" path)))
'("kernel" "loader" "loader.efi" "device.hints" "defaults" "lua" "loader.conf")) '("kernel" "loader" "loader.efi" "device.hints" "defaults" "lua" "loader.conf"))
(symlink-force "/run/current-system/usr/local/bin/fruix"
(string-append rootfs "/usr/local/bin/fruix"))
(symlink-force "/run/current-system/usr/local/etc/rc.d/fruix-activate" (symlink-force "/run/current-system/usr/local/etc/rc.d/fruix-activate"
(string-append rootfs "/usr/local/etc/rc.d/fruix-activate")) (string-append rootfs "/usr/local/etc/rc.d/fruix-activate"))
(symlink-force "/run/current-system/usr/local/etc/rc.d/fruix-shepherd" (symlink-force "/run/current-system/usr/local/etc/rc.d/fruix-shepherd"

View File

@@ -295,7 +295,8 @@
"metadata/host-base-provenance.scm" "metadata/host-base-provenance.scm"
"metadata/store-layout.scm" "metadata/store-layout.scm"
"activate" "activate"
"shepherd/init.scm") "shepherd/init.scm"
"usr/local/bin/fruix")
(if (pid1-init-mode? os) (if (pid1-init-mode? os)
'("boot/fruix-pid1") '("boot/fruix-pid1")
'()) '())

View File

@@ -467,6 +467,290 @@
"}\n\n" "}\n\n"
"load_rc_config $name\n" "load_rc_config $name\n"
"run_rc_command \"$1\"\n"))) "run_rc_command \"$1\"\n")))
(define (render-installed-system-fruix os)
(string-append
"#!/bin/sh\n"
"set -eu\n"
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin\n"
"tool_closure=$(readlink /run/current-system 2>/dev/null || true)\n"
"if [ -n \"$tool_closure\" ]; then\n"
" PATH=\"$tool_closure/profile/bin:$tool_closure/profile/sbin:$tool_closure/profile/usr/bin:$tool_closure/profile/usr/sbin:$PATH\"\n"
"fi\n"
"export PATH\n\n"
"system_root=/var/lib/fruix/system\n"
"generations_root=\"$system_root/generations\"\n"
"current_link=\"$system_root/current\"\n"
"current_generation_file=\"$system_root/current-generation\"\n"
"rollback_link=\"$system_root/rollback\"\n"
"rollback_generation_file=\"$system_root/rollback-generation\"\n"
"gcroots_root=/frx/var/fruix/gcroots\n"
"run_current_link=/run/current-system\n"
"layout_version=2\n"
"host_name='" (operating-system-host-name os) "'\n"
"ready_marker='" (operating-system-ready-marker os) "'\n"
"init_mode='" (symbol->string (operating-system-init-mode os)) "'\n\n"
"usage()\n"
"{\n"
" cat <<'EOF'\n"
"Usage: fruix system status\n"
" fruix system switch /frx/store/...-fruix-system-...\n"
" fruix system rollback\n"
"EOF\n"
"}\n\n"
"die()\n"
"{\n"
" echo \"fruix: $*\" >&2\n"
" exit 1\n"
"}\n\n"
"read_link_maybe()\n"
"{\n"
" if [ -L \"$1\" ]; then\n"
" readlink \"$1\"\n"
" fi\n"
"}\n\n"
"read_file_maybe()\n"
"{\n"
" if [ -f \"$1\" ]; then\n"
" tr -d '\\n' < \"$1\"\n"
" fi\n"
"}\n\n"
"symlink_force()\n"
"{\n"
" target=$1\n"
" link_name=$2\n"
" tmp_link=\"${link_name}.new.$$\"\n"
" mkdir -p \"$(dirname \"$link_name\")\"\n"
" if [ \"$link_name\" = \"$run_current_link\" ]; then\n"
" rm -f \"$tmp_link\"\n"
" ln -s \"$target\" \"$tmp_link\"\n"
" mv -h -f \"$tmp_link\" \"$link_name\"\n"
" else\n"
" rm -f \"$link_name\"\n"
" ln -s \"$target\" \"$link_name\"\n"
" fi\n"
"}\n\n"
"validate_closure()\n"
"{\n"
" closure=$1\n"
" [ -d \"$closure\" ] || die \"missing closure directory: $closure\"\n"
" [ -f \"$closure/activate\" ] || die \"closure is missing activate script: $closure\"\n"
" [ -f \"$closure/shepherd/init.scm\" ] || die \"closure is missing shepherd config: $closure\"\n"
" [ -f \"$closure/boot/loader.efi\" ] || die \"closure is missing loader.efi: $closure\"\n"
"}\n\n"
"max_generation_number()\n"
"{\n"
" max=0\n"
" if [ -d \"$generations_root\" ]; then\n"
" for path in \"$generations_root\"/*; do\n"
" [ -d \"$path\" ] || continue\n"
" base=$(basename \"$path\")\n"
" case \"$base\" in\n"
" ''|*[!0-9]*)\n"
" continue\n"
" ;;\n"
" esac\n"
" if [ \"$base\" -gt \"$max\" ]; then\n"
" max=$base\n"
" fi\n"
" done\n"
" fi\n"
" printf '%s\\n' \"$max\"\n"
"}\n\n"
"next_generation_number()\n"
"{\n"
" max=$(max_generation_number)\n"
" printf '%s\\n' $((max + 1))\n"
"}\n\n"
"write_generation_metadata()\n"
"{\n"
" generation=$1\n"
" closure=$2\n"
" action=$3\n"
" previous_generation=$4\n"
" previous_closure=$5\n"
" generation_dir=\"$generations_root/$generation\"\n"
" install_metadata_path=\"/var/lib/fruix/system/generations/$generation/install.scm\"\n"
" cat > \"$generation_dir/metadata.scm\" <<EOF\n"
"((system-generation-version . \"$layout_version\")\n"
" (generation-number . $generation)\n"
" (host-name . \"$host_name\")\n"
" (ready-marker . \"$ready_marker\")\n"
" (init-mode . $init_mode)\n"
" (closure-path . \"$closure\")\n"
" (parameters-file . \"$closure/parameters.scm\")\n"
" (freebsd-base-file . \"$closure/metadata/freebsd-base.scm\")\n"
" (freebsd-source-file . \"$closure/metadata/freebsd-source.scm\")\n"
" (freebsd-source-materializations-file . \"$closure/metadata/freebsd-source-materializations.scm\")\n"
" (host-base-provenance-file . \"$closure/metadata/host-base-provenance.scm\")\n"
" (store-layout-file . \"$closure/metadata/store-layout.scm\")\n"
" (install-metadata-path . \"$install_metadata_path\")\n"
" (deployment-action . \"$action\")\n"
" (previous-generation-number . \"$previous_generation\")\n"
" (previous-closure-path . \"$previous_closure\"))\n"
"EOF\n"
" chmod 644 \"$generation_dir/metadata.scm\"\n"
"}\n\n"
"write_generation_provenance()\n"
"{\n"
" generation=$1\n"
" closure=$2\n"
" generation_dir=\"$generations_root/$generation\"\n"
" cat > \"$generation_dir/provenance.scm\" <<EOF\n"
"((closure-path . \"$closure\")\n"
" (parameters-file . \"$closure/parameters.scm\")\n"
" (freebsd-base-file . \"$closure/metadata/freebsd-base.scm\")\n"
" (freebsd-source-file . \"$closure/metadata/freebsd-source.scm\")\n"
" (freebsd-source-materializations-file . \"$closure/metadata/freebsd-source-materializations.scm\")\n"
" (host-base-provenance-file . \"$closure/metadata/host-base-provenance.scm\")\n"
" (store-layout-file . \"$closure/metadata/store-layout.scm\"))\n"
"EOF\n"
" chmod 644 \"$generation_dir/provenance.scm\"\n"
"}\n\n"
"write_generation_install_metadata()\n"
"{\n"
" generation=$1\n"
" closure=$2\n"
" action=$3\n"
" previous_generation=$4\n"
" previous_closure=$5\n"
" generation_dir=\"$generations_root/$generation\"\n"
" cat > \"$generation_dir/install.scm\" <<EOF\n"
"((deployment-kind . \"$action\")\n"
" (generation-number . $generation)\n"
" (closure-path . \"$closure\")\n"
" (previous-generation-number . \"$previous_generation\")\n"
" (previous-closure-path . \"$previous_closure\")\n"
" (freebsd-base-file . \"$closure/metadata/freebsd-base.scm\")\n"
" (freebsd-source-file . \"$closure/metadata/freebsd-source.scm\")\n"
" (freebsd-source-materializations-file . \"$closure/metadata/freebsd-source-materializations.scm\")\n"
" (store-layout-file . \"$closure/metadata/store-layout.scm\"))\n"
"EOF\n"
" chmod 644 \"$generation_dir/install.scm\"\n"
"}\n\n"
"prepare_generation()\n"
"{\n"
" generation=$1\n"
" closure=$2\n"
" action=$3\n"
" previous_generation=$4\n"
" previous_closure=$5\n"
" generation_dir=\"$generations_root/$generation\"\n"
" mkdir -p \"$generation_dir\"\n"
" symlink_force \"$closure\" \"$generation_dir/closure\"\n"
" write_generation_metadata \"$generation\" \"$closure\" \"$action\" \"$previous_generation\" \"$previous_closure\"\n"
" write_generation_provenance \"$generation\" \"$closure\"\n"
" write_generation_install_metadata \"$generation\" \"$closure\" \"$action\" \"$previous_generation\" \"$previous_closure\"\n"
"}\n\n"
"update_efi_loader()\n"
"{\n"
" closure=$1\n"
" [ -e /dev/gpt/efiboot ] || return 0\n"
" esp_mount=$(mktemp -d /tmp/fruix-efiboot.XXXXXX)\n"
" if /sbin/mount -t msdosfs /dev/gpt/efiboot \"$esp_mount\" >/dev/null 2>&1; then\n"
" mkdir -p \"$esp_mount/EFI/BOOT\"\n"
" cp \"$closure/boot/loader.efi\" \"$esp_mount/EFI/BOOT/BOOTX64.EFI\"\n"
" sync\n"
" /sbin/umount \"$esp_mount\" >/dev/null 2>&1 || true\n"
" fi\n"
" rmdir \"$esp_mount\" >/dev/null 2>&1 || true\n"
"}\n\n"
"status()\n"
"{\n"
" current_generation=$(read_file_maybe \"$current_generation_file\")\n"
" current_generation_link=$(read_link_maybe \"$current_link\")\n"
" current_closure=$(read_link_maybe \"$run_current_link\")\n"
" rollback_generation=$(read_file_maybe \"$rollback_generation_file\")\n"
" rollback_generation_link=$(read_link_maybe \"$rollback_link\")\n"
" rollback_closure=\"\"\n"
" if [ -n \"$rollback_generation_link\" ] && [ -L \"$system_root/$rollback_generation_link/closure\" ]; then\n"
" rollback_closure=$(readlink \"$system_root/$rollback_generation_link/closure\")\n"
" fi\n"
" printf 'current_generation=%s\\n' \"$current_generation\"\n"
" printf 'current_link=%s\\n' \"$current_generation_link\"\n"
" printf 'current_closure=%s\\n' \"$current_closure\"\n"
" printf 'rollback_generation=%s\\n' \"$rollback_generation\"\n"
" printf 'rollback_link=%s\\n' \"$rollback_generation_link\"\n"
" printf 'rollback_closure=%s\\n' \"$rollback_closure\"\n"
"}\n\n"
"switch_to_closure()\n"
"{\n"
" target_closure=$1\n"
" validate_closure \"$target_closure\"\n"
" current_generation=$(read_file_maybe \"$current_generation_file\")\n"
" current_closure=$(read_link_maybe \"$run_current_link\")\n"
" [ -n \"$current_generation\" ] || die \"missing current generation metadata\"\n"
" [ -n \"$current_closure\" ] || die \"missing /run/current-system target\"\n"
" if [ \"$target_closure\" = \"$current_closure\" ]; then\n"
" status\n"
" return 0\n"
" fi\n"
" new_generation=$(next_generation_number)\n"
" prepare_generation \"$new_generation\" \"$target_closure\" switch \"$current_generation\" \"$current_closure\"\n"
" symlink_force \"generations/$current_generation\" \"$rollback_link\"\n"
" printf '%s\\n' \"$current_generation\" > \"$rollback_generation_file\"\n"
" symlink_force \"$current_closure\" \"$gcroots_root/rollback-system\"\n"
" symlink_force \"generations/$new_generation\" \"$current_link\"\n"
" printf '%s\\n' \"$new_generation\" > \"$current_generation_file\"\n"
" symlink_force \"$target_closure\" \"$gcroots_root/system-$new_generation\"\n"
" symlink_force \"$target_closure\" \"$gcroots_root/current-system\"\n"
" symlink_force \"$target_closure\" \"$run_current_link\"\n"
" update_efi_loader \"$target_closure\"\n"
" status\n"
"}\n\n"
"rollback_current_generation()\n"
"{\n"
" rollback_generation=$(read_file_maybe \"$rollback_generation_file\")\n"
" rollback_generation_link=$(read_link_maybe \"$rollback_link\")\n"
" [ -n \"$rollback_generation\" ] || die \"no rollback generation is recorded\"\n"
" [ -n \"$rollback_generation_link\" ] || die \"no rollback link is recorded\"\n"
" rollback_closure=$(read_link_maybe \"$system_root/$rollback_generation_link/closure\")\n"
" [ -n \"$rollback_closure\" ] || die \"rollback generation has no closure link\"\n"
" current_generation=$(read_file_maybe \"$current_generation_file\")\n"
" current_closure=$(read_link_maybe \"$run_current_link\")\n"
" [ -n \"$current_generation\" ] || die \"missing current generation metadata\"\n"
" [ -n \"$current_closure\" ] || die \"missing current closure link\"\n"
" symlink_force \"generations/$current_generation\" \"$rollback_link\"\n"
" printf '%s\\n' \"$current_generation\" > \"$rollback_generation_file\"\n"
" symlink_force \"$current_closure\" \"$gcroots_root/rollback-system\"\n"
" symlink_force \"$rollback_generation_link\" \"$current_link\"\n"
" printf '%s\\n' \"$rollback_generation\" > \"$current_generation_file\"\n"
" symlink_force \"$rollback_closure\" \"$gcroots_root/current-system\"\n"
" symlink_force \"$rollback_closure\" \"$run_current_link\"\n"
" update_efi_loader \"$rollback_closure\"\n"
" status\n"
"}\n\n"
"case \"${1:-}\" in\n"
" system)\n"
" case \"${2:-}\" in\n"
" status)\n"
" [ $# -eq 2 ] || { usage >&2; exit 1; }\n"
" status\n"
" ;;\n"
" switch)\n"
" [ $# -eq 3 ] || { usage >&2; exit 1; }\n"
" switch_to_closure \"$3\"\n"
" ;;\n"
" rollback)\n"
" [ $# -eq 2 ] || { usage >&2; exit 1; }\n"
" rollback_current_generation\n"
" ;;\n"
" --help|-h|'')\n"
" usage\n"
" ;;\n"
" *)\n"
" usage >&2\n"
" exit 1\n"
" ;;\n"
" esac\n"
" ;;\n"
" --help|-h|'')\n"
" usage\n"
" ;;\n"
" *)\n"
" usage >&2\n"
" exit 1\n"
" ;;\n"
"esac\n"))
(define* (operating-system-generated-files os #:key guile-store guile-extra-store shepherd-store) (define* (operating-system-generated-files os #:key guile-store guile-extra-store shepherd-store)
@@ -486,7 +770,8 @@
#:guile-store guile-store #:guile-store guile-store
#:guile-extra-store guile-extra-store #:guile-extra-store guile-extra-store
#:shepherd-store shepherd-store)) #:shepherd-store shepherd-store))
("shepherd/init.scm" . ,(render-shepherd-config os))) ("shepherd/init.scm" . ,(render-shepherd-config os))
("usr/local/bin/fruix" . ,(render-installed-system-fruix os)))
(if (pid1-init-mode? os) (if (pid1-init-mode? os)
`(("boot/fruix-pid1" . ,(render-pid1-script os shepherd-store guile-store guile-extra-store))) `(("boot/fruix-pid1" . ,(render-pid1-script os shepherd-store guile-store guile-extra-store)))
'()) '())

View File

@@ -0,0 +1,91 @@
(use-modules (fruix system freebsd)
(fruix packages freebsd))
(define phase19-source
(freebsd-source
#:name "__SOURCE_NAME__"
#:kind 'git
#:ref "__SOURCE_REF__"
#:commit "__SOURCE_COMMIT__"))
(define phase19-base
(freebsd-base
#:name "__BASE_NAME__"
#:version-label "__BASE_VERSION_LABEL__"
#:release "__BASE_RELEASE__"
#:branch "__BASE_BRANCH__"
#:source phase19-source
#:source-root "__DECLARED_SOURCE_ROOT__"
#:target "amd64"
#:target-arch "amd64"
#:kernconf "GENERIC"))
(define phase19-operating-system
(operating-system
#:host-name "__HOST_NAME__"
#:freebsd-base phase19-base
#:kernel (freebsd-native-kernel-for phase19-base)
#:bootloader (freebsd-native-bootloader-for phase19-base)
#:base-packages (freebsd-native-system-packages-for phase19-base)
#:groups (list (user-group #:name "wheel" #:gid 0 #:system? #t)
(user-group #:name "sshd" #:gid 22 #:system? #t)
(user-group #:name "_dhcp" #:gid 65 #:system? #t)
(user-group #:name "operator" #:gid 1000 #:system? #f))
#:users (list (user-account #:name "root"
#:uid 0
#:group "wheel"
#:comment "Charlie &"
#:home "/root"
#:shell "/bin/sh"
#:system? #t)
(user-account #:name "sshd"
#:uid 22
#:group "sshd"
#:comment "Secure Shell Daemon"
#:home "/var/empty"
#:shell "/usr/sbin/nologin"
#:system? #t)
(user-account #:name "_dhcp"
#:uid 65
#:group "_dhcp"
#:comment "dhcp programs"
#:home "/var/empty"
#:shell "/usr/sbin/nologin"
#:system? #t)
(user-account #:name "operator"
#:uid 1000
#:group "operator"
#:supplementary-groups '("wheel")
#:comment "Fruix Operator"
#:home "/home/operator"
#:shell "/bin/sh"
#:system? #f))
#:file-systems (list (file-system #:device "/dev/gpt/fruix-root"
#:mount-point "/"
#:type "ufs"
#:options "rw"
#:needed-for-boot? #t)
(file-system #:device "devfs"
#:mount-point "/dev"
#:type "devfs"
#:options "rw"
#:needed-for-boot? #t)
(file-system #:device "tmpfs"
#:mount-point "/tmp"
#:type "tmpfs"
#:options "rw,size=64m"))
#:services '(shepherd ready-marker sshd)
#:loader-entries '(("autoboot_delay" . "1")
("boot_multicons" . "YES")
("boot_serial" . "YES")
("console" . "comconsole,vidconsole"))
#:rc-conf-entries '(("clear_tmp_enable" . "NO")
("hostid_enable" . "NO")
("sendmail_enable" . "NONE")
("sshd_enable" . "YES")
("ifconfig_xn0" . "SYNCDHCP")
("ifconfig_em0" . "SYNCDHCP")
("ifconfig_vtnet0" . "SYNCDHCP"))
#:init-mode 'freebsd-init+rc.d-shepherd
#:ready-marker "/var/lib/fruix/ready"
#:root-authorized-keys '("__ROOT_AUTHORIZED_KEY__")))

View File

@@ -0,0 +1,388 @@
#!/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/phase19-generation-rollback-operating-system.scm.in}
system_name=${SYSTEM_NAME:-phase19-operating-system}
store_dir=${STORE_DIR:-/frx/store}
disk_capacity=${DISK_CAPACITY:-12g}
root_size=${ROOT_SIZE:-10g}
qemu_smp=${QEMU_SMP:-2}
ssh_port=${QEMU_SSH_PORT:-10024}
base_name=${BASE_NAME:-phase19-generation-layout}
base_version_label=${BASE_VERSION_LABEL:-15.0-STABLE-generation-layout}
base_release=${BASE_RELEASE:-15.0-STABLE}
base_branch=${BASE_BRANCH:-stable/15}
source_name=${SOURCE_NAME:-stable15-generation-layout-source}
source_ref=${SOURCE_REF:-stable/15}
source_commit=${SOURCE_COMMIT:-332708a606f6bf0841c1d4a74c0d067f5640fe89}
declared_source_root=${DECLARED_SOURCE_ROOT:-/var/empty/fruix-unused-source-root-generation-layout}
current_host_name=${CURRENT_HOST_NAME:-fruix-freebsd-current}
candidate_host_name=${CANDIDATE_HOST_NAME:-fruix-freebsd-canary}
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}
cleanup=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 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
}
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-phase19-installed-system-rollback-qemu.XXXXXX)
cleanup=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup=0
fi
current_os_file=$workdir/current-operating-system.scm
candidate_os_file=$workdir/candidate-operating-system.scm
current_install_out=$workdir/current-install.txt
candidate_build_out=$workdir/candidate-build.txt
target_image=$workdir/installed.img
candidate_store_items=$workdir/candidate-store-items.txt
stage_log=$workdir/stage-candidate-store.txt
serial_log=$workdir/serial.log
qemu_pidfile=$workdir/qemu.pid
uefi_vars=$workdir/QEMU_UEFI_VARS.fd
metadata_file=$workdir/phase19-installed-system-rollback-qemu-metadata.txt
switch_status_file=$workdir/switch-status.txt
rollback_status_file=$workdir/rollback-status.txt
boot_current_status_file=$workdir/boot-current-status.txt
boot_candidate_status_file=$workdir/boot-candidate-status.txt
boot_rollback_status_file=$workdir/boot-rollback-status.txt
generation2_metadata_file=$workdir/generation2-metadata.scm
generation2_install_file=$workdir/generation2-install.scm
mnt_root=$workdir/mnt-root
md_unit=
cleanup_workdir() {
if [ -f "$qemu_pidfile" ]; then
sudo kill "$(sudo cat "$qemu_pidfile")" >/dev/null 2>&1 || true
fi
if [ -n "$md_unit" ]; then
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
render_os() {
output=$1
host_name=$2
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|__HOST_NAME__|$host_name|g" \
-e "s|__ROOT_AUTHORIZED_KEY__|$root_authorized_key|g" \
"$os_template" > "$output"
}
render_os "$current_os_file" "$current_host_name"
render_os "$candidate_os_file" "$candidate_host_name"
cp /usr/local/share/edk2-qemu/QEMU_UEFI_VARS-x86_64.fd "$uefi_vars"
mkdir -p "$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}" \
"$@"
}
field() {
name=$1
file=$2
sed -n "s/^$name=//p" "$file" | tail -n 1
}
status_field() {
name=$1
file=$2
sed -n "s/^$name=//p" "$file" | tail -n 1
}
wait_for_ssh() {
for attempt in $(jot 120 1 120); do
if ssh_guest 'service sshd onestatus >/dev/null 2>&1' >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
ssh_guest() {
ssh -p "$ssh_port" -i "$root_ssh_private_key_file" \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
root@127.0.0.1 "$@"
}
reboot_guest() {
ssh_guest 'shutdown -r now >/dev/null 2>&1 || reboot >/dev/null 2>&1 || true' >/dev/null 2>&1 || true
sleep 5
wait_for_ssh || {
echo "guest did not return over SSH after reboot" >&2
exit 1
}
}
capture_status() {
output_file=$1
ssh_guest '/usr/local/bin/fruix system status' > "$output_file"
}
action_env "$fruix_cmd" system install "$current_os_file" \
--system "$system_name" \
--store "$store_dir" \
--target "$target_image" \
--disk-capacity "$disk_capacity" \
--root-size "$root_size" > "$current_install_out"
current_closure_path=$(field closure_path "$current_install_out")
install_metadata_path=$(field install_metadata_path "$current_install_out")
materialized_source_store=$(field materialized_source_stores "$current_install_out")
[ -n "$current_closure_path" ] || { echo "missing current closure path" >&2; exit 1; }
[ "$install_metadata_path" = /var/lib/fruix/install.scm ] || { echo "unexpected install metadata path: $install_metadata_path" >&2; exit 1; }
action_env "$fruix_cmd" system build "$candidate_os_file" \
--system "$system_name" \
--store "$store_dir" > "$candidate_build_out"
candidate_closure_path=$(field closure_path "$candidate_build_out")
[ -n "$candidate_closure_path" ] || { echo "missing candidate closure path" >&2; exit 1; }
[ "$candidate_closure_path" != "$current_closure_path" ] || { echo "candidate closure unexpectedly matches current closure" >&2; exit 1; }
{
printf '%s\n' "$candidate_closure_path"
cat "$candidate_closure_path/.references"
} | awk 'NF { print }' | sort -u > "$candidate_store_items"
candidate_store_item_count=$(wc -l < "$candidate_store_items" | tr -d ' ')
md=$(sudo mdconfig -a -t vnode -f "$target_image")
md_unit=${md#md}
sudo mount -t ufs "/dev/${md}p2" "$mnt_root"
sudo mkdir -p "$mnt_root/frx/store"
: > "$stage_log"
while IFS= read -r item; do
[ -n "$item" ] || continue
item_base=$(basename "$item")
if [ -e "$mnt_root/frx/store/$item_base" ]; then
printf 'already-present=%s\n' "$item_base" >> "$stage_log"
continue
fi
printf 'copy=%s\n' "$item_base" >> "$stage_log"
sudo sh -c "cd '$store_dir' && pax -rw -pe '$item_base' '$mnt_root/frx/store'"
done < "$candidate_store_items"
sudo sync
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:$serial_log" \
-monitor none \
-pidfile "$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="$uefi_vars" \
-drive if=virtio,format=raw,file="$target_image" \
-netdev user,id=net0,hostfwd=tcp::${ssh_port}-:22 \
-device virtio-net-pci,netdev=net0
wait_for_ssh || {
echo "guest never became reachable over SSH" >&2
exit 1
}
capture_status "$boot_current_status_file"
boot_current_closure=$(ssh_guest 'readlink /run/current-system')
boot_current_hostname=$(ssh_guest 'hostname')
boot_current_generation=$(status_field current_generation "$boot_current_status_file")
boot_current_link=$(status_field current_link "$boot_current_status_file")
boot_current_rollback_generation=$(status_field rollback_generation "$boot_current_status_file")
[ "$boot_current_closure" = "$current_closure_path" ] || { echo "unexpected current closure after initial boot: $boot_current_closure" >&2; exit 1; }
[ "$boot_current_hostname" = "$current_host_name" ] || { echo "unexpected hostname after initial boot: $boot_current_hostname" >&2; exit 1; }
[ "$boot_current_generation" = 1 ] || { echo "unexpected initial generation: $boot_current_generation" >&2; exit 1; }
[ "$boot_current_link" = generations/1 ] || { echo "unexpected initial current link: $boot_current_link" >&2; exit 1; }
[ -z "$boot_current_rollback_generation" ] || { echo "rollback generation should be empty before switch" >&2; exit 1; }
ssh_guest 'test -x /usr/local/bin/fruix'
ssh_guest "/usr/local/bin/fruix system switch $candidate_closure_path" > "$switch_status_file"
switch_current_generation=$(status_field current_generation "$switch_status_file")
switch_current_link=$(status_field current_link "$switch_status_file")
switch_current_closure=$(status_field current_closure "$switch_status_file")
switch_rollback_generation=$(status_field rollback_generation "$switch_status_file")
switch_rollback_link=$(status_field rollback_link "$switch_status_file")
switch_rollback_closure=$(status_field rollback_closure "$switch_status_file")
[ "$switch_current_generation" = 2 ] || { echo "unexpected generation after switch: $switch_current_generation" >&2; exit 1; }
[ "$switch_current_link" = generations/2 ] || { echo "unexpected current link after switch: $switch_current_link" >&2; exit 1; }
[ "$switch_current_closure" = "$candidate_closure_path" ] || { echo "unexpected current closure after switch: $switch_current_closure" >&2; exit 1; }
[ "$switch_rollback_generation" = 1 ] || { echo "unexpected rollback generation after switch: $switch_rollback_generation" >&2; exit 1; }
[ "$switch_rollback_link" = generations/1 ] || { echo "unexpected rollback link after switch: $switch_rollback_link" >&2; exit 1; }
[ "$switch_rollback_closure" = "$current_closure_path" ] || { echo "unexpected rollback closure after switch: $switch_rollback_closure" >&2; exit 1; }
[ "$(ssh_guest 'readlink /frx/var/fruix/gcroots/current-system')" = "$candidate_closure_path" ] || { echo "unexpected current-system gc root after switch" >&2; exit 1; }
[ "$(ssh_guest 'readlink /frx/var/fruix/gcroots/rollback-system')" = "$current_closure_path" ] || { echo "unexpected rollback-system gc root after switch" >&2; exit 1; }
[ "$(ssh_guest 'readlink /frx/var/fruix/gcroots/system-2')" = "$candidate_closure_path" ] || { echo "unexpected system-2 gc root after switch" >&2; exit 1; }
ssh_guest 'test -f /var/lib/fruix/system/generations/2/metadata.scm'
ssh_guest 'test -f /var/lib/fruix/system/generations/2/provenance.scm'
ssh_guest 'test -f /var/lib/fruix/system/generations/2/install.scm'
ssh_guest "cat /var/lib/fruix/system/generations/2/metadata.scm" > "$generation2_metadata_file"
ssh_guest "cat /var/lib/fruix/system/generations/2/install.scm" > "$generation2_install_file"
case "$(cat "$generation2_metadata_file")" in
*"$candidate_closure_path"*"$current_closure_path"*) : ;;
*) echo "generation 2 metadata does not record both candidate and previous closure paths" >&2; exit 1 ;;
esac
case "$(cat "$generation2_install_file")" in
*"(deployment-kind . \"switch\")"*"$candidate_closure_path"*) : ;;
*) echo "generation 2 install metadata does not record switch provenance" >&2; exit 1 ;;
esac
reboot_guest
capture_status "$boot_candidate_status_file"
boot_candidate_closure=$(ssh_guest 'readlink /run/current-system')
boot_candidate_hostname=$(ssh_guest 'hostname')
boot_candidate_generation=$(status_field current_generation "$boot_candidate_status_file")
boot_candidate_rollback_generation=$(status_field rollback_generation "$boot_candidate_status_file")
boot_candidate_rollback_closure=$(status_field rollback_closure "$boot_candidate_status_file")
[ "$boot_candidate_closure" = "$candidate_closure_path" ] || { echo "unexpected closure after switch reboot: $boot_candidate_closure" >&2; exit 1; }
[ "$boot_candidate_hostname" = "$candidate_host_name" ] || { echo "unexpected hostname after switch reboot: $boot_candidate_hostname" >&2; exit 1; }
[ "$boot_candidate_generation" = 2 ] || { echo "unexpected generation after switch reboot: $boot_candidate_generation" >&2; exit 1; }
[ "$boot_candidate_rollback_generation" = 1 ] || { echo "unexpected rollback generation after switch reboot: $boot_candidate_rollback_generation" >&2; exit 1; }
[ "$boot_candidate_rollback_closure" = "$current_closure_path" ] || { echo "unexpected rollback closure after switch reboot: $boot_candidate_rollback_closure" >&2; exit 1; }
ssh_guest '/usr/local/bin/fruix system rollback' > "$rollback_status_file"
rollback_current_generation=$(status_field current_generation "$rollback_status_file")
rollback_current_link=$(status_field current_link "$rollback_status_file")
rollback_current_closure=$(status_field current_closure "$rollback_status_file")
rollback_rollback_generation=$(status_field rollback_generation "$rollback_status_file")
rollback_rollback_link=$(status_field rollback_link "$rollback_status_file")
rollback_rollback_closure=$(status_field rollback_closure "$rollback_status_file")
[ "$rollback_current_generation" = 1 ] || { echo "unexpected generation after rollback: $rollback_current_generation" >&2; exit 1; }
[ "$rollback_current_link" = generations/1 ] || { echo "unexpected current link after rollback: $rollback_current_link" >&2; exit 1; }
[ "$rollback_current_closure" = "$current_closure_path" ] || { echo "unexpected current closure after rollback: $rollback_current_closure" >&2; exit 1; }
[ "$rollback_rollback_generation" = 2 ] || { echo "unexpected rollback generation after rollback: $rollback_rollback_generation" >&2; exit 1; }
[ "$rollback_rollback_link" = generations/2 ] || { echo "unexpected rollback link after rollback: $rollback_rollback_link" >&2; exit 1; }
[ "$rollback_rollback_closure" = "$candidate_closure_path" ] || { echo "unexpected rollback closure after rollback: $rollback_rollback_closure" >&2; exit 1; }
[ "$(ssh_guest 'readlink /frx/var/fruix/gcroots/current-system')" = "$current_closure_path" ] || { echo "unexpected current-system gc root after rollback" >&2; exit 1; }
[ "$(ssh_guest 'readlink /frx/var/fruix/gcroots/rollback-system')" = "$candidate_closure_path" ] || { echo "unexpected rollback-system gc root after rollback" >&2; exit 1; }
reboot_guest
capture_status "$boot_rollback_status_file"
boot_rollback_closure=$(ssh_guest 'readlink /run/current-system')
boot_rollback_hostname=$(ssh_guest 'hostname')
boot_rollback_generation=$(status_field current_generation "$boot_rollback_status_file")
boot_rollback_rollback_generation=$(status_field rollback_generation "$boot_rollback_status_file")
boot_rollback_rollback_closure=$(status_field rollback_closure "$boot_rollback_status_file")
shepherd_status=$(ssh_guest '/usr/local/etc/rc.d/fruix-shepherd onestatus >/dev/null 2>&1 && echo running || echo stopped')
sshd_status=$(ssh_guest 'service sshd onestatus >/dev/null 2>&1 && echo running || echo stopped')
activate_log=$(ssh_guest 'cat /var/log/fruix-activate.log 2>/dev/null || true' | tr '\n' ' ')
[ "$boot_rollback_closure" = "$current_closure_path" ] || { echo "unexpected closure after rollback reboot: $boot_rollback_closure" >&2; exit 1; }
[ "$boot_rollback_hostname" = "$current_host_name" ] || { echo "unexpected hostname after rollback reboot: $boot_rollback_hostname" >&2; exit 1; }
[ "$boot_rollback_generation" = 1 ] || { echo "unexpected generation after rollback reboot: $boot_rollback_generation" >&2; exit 1; }
[ "$boot_rollback_rollback_generation" = 2 ] || { echo "unexpected rollback generation after rollback reboot: $boot_rollback_rollback_generation" >&2; exit 1; }
[ "$boot_rollback_rollback_closure" = "$candidate_closure_path" ] || { echo "unexpected rollback closure after rollback reboot: $boot_rollback_rollback_closure" >&2; exit 1; }
[ "$shepherd_status" = running ] || { echo "fruix-shepherd is not running after rollback reboot" >&2; exit 1; }
[ "$sshd_status" = running ] || { echo "sshd is not running after rollback reboot" >&2; exit 1; }
case "$activate_log" in
*fruix-activate:done*) : ;;
*) echo "activation log does not show success after rollback workflow" >&2; exit 1 ;;
esac
cat >"$metadata_file" <<EOF
workdir=$workdir
current_os_file=$current_os_file
candidate_os_file=$candidate_os_file
target_image=$target_image
current_closure_path=$current_closure_path
candidate_closure_path=$candidate_closure_path
current_host_name=$current_host_name
candidate_host_name=$candidate_host_name
candidate_store_item_count=$candidate_store_item_count
candidate_store_items=$candidate_store_items
stage_log=$stage_log
serial_log=$serial_log
install_metadata_path=$install_metadata_path
materialized_source_store=$materialized_source_store
switch_status_file=$switch_status_file
rollback_status_file=$rollback_status_file
boot_current_status_file=$boot_current_status_file
boot_candidate_status_file=$boot_candidate_status_file
boot_rollback_status_file=$boot_rollback_status_file
generation2_metadata_file=$generation2_metadata_file
generation2_install_file=$generation2_install_file
final_current_generation=$boot_rollback_generation
final_current_closure=$boot_rollback_closure
final_rollback_generation=$boot_rollback_rollback_generation
final_rollback_closure=$boot_rollback_rollback_closure
shepherd_status=$shepherd_status
sshd_status=$sshd_status
boot_backend=qemu-uefi-tcg
installed_system_switch=ok
installed_system_rollback=ok
EOF
if [ -n "$metadata_target" ]; then
mkdir -p "$(dirname "$metadata_target")"
cp "$metadata_file" "$metadata_target"
fi
printf 'PASS phase19-installed-system-rollback-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"