Add fruix system command for FreeBSD

This commit is contained in:
2026-04-02 10:31:24 +02:00
parent a69864cc0c
commit b037427c22
5 changed files with 581 additions and 0 deletions

51
bin/fruix Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
project_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
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}
script=$project_root/scripts/fruix.scm
if [ ! -x "$guile_bin" ]; then
echo "Guile binary is not executable: $guile_bin" >&2
exit 1
fi
ensure_built() {
if [ ! -d "$guile_extra_prefix/share/guile/site" ] || \
! GUILE_LOAD_PATH="$guile_extra_prefix/share/guile/site/3.0${GUILE_LOAD_PATH:+:$GUILE_LOAD_PATH}" \
GUILE_LOAD_COMPILED_PATH="$guile_extra_prefix/lib/guile/3.0/site-ccache${GUILE_LOAD_COMPILED_PATH:+:$GUILE_LOAD_COMPILED_PATH}" \
GUILE_EXTENSIONS_PATH="$guile_extra_prefix/lib/guile/3.0/extensions${GUILE_EXTENSIONS_PATH:+:$GUILE_EXTENSIONS_PATH}" \
LD_LIBRARY_PATH="$guile_extra_prefix/lib:/tmp/guile-freebsd-validate-install/lib:/usr/local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$guile_bin" -c '(catch #t (lambda () (use-modules (fibers)) (display "ok") (newline)) (lambda _ (display "missing") (newline)))' | grep -qx ok; then
METADATA_OUT= ENV_OUT= "$project_root/tests/shepherd/build-local-guile-fibers.sh"
fi
if [ ! -x "$shepherd_prefix/bin/shepherd" ] || [ ! -x "$shepherd_prefix/bin/herd" ]; then
METADATA_OUT= ENV_OUT= GUILE_EXTRA_PREFIX="$guile_extra_prefix" "$project_root/tests/shepherd/build-local-shepherd.sh"
fi
}
ensure_built
guile_prefix=$(CDPATH= cd -- "$(dirname "$guile_bin")/.." && pwd)
guile_lib_dir=$guile_prefix/lib
if [ -n "${GUILE_LOAD_PATH:-}" ]; then
guile_load_path="$project_root/modules:$guix_source_dir:$GUILE_LOAD_PATH"
else
guile_load_path="$project_root/modules:$guix_source_dir"
fi
exec env \
GUILE_AUTO_COMPILE=0 \
GUILE_LOAD_PATH="$guile_load_path" \
LD_LIBRARY_PATH="$guile_lib_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
GUILE_PREFIX="$guile_prefix" \
GUILE_EXTRA_PREFIX="$guile_extra_prefix" \
SHEPHERD_PREFIX="$shepherd_prefix" \
GUIX_SOURCE_DIR="$guix_source_dir" \
FRUIX_PROJECT_ROOT="$project_root" \
"$guile_bin" --no-auto-compile -s "$script" "$@"

View File

@@ -2291,3 +2291,65 @@ Next recommended step:
- login-class warnings around `daemon` - login-class warnings around `daemon`
- the `gpart: Unknown command: show` rc noise - the `gpart: Unknown command: show` rc noise
- residual syslog/cron/runtime polish issues where they still matter - residual syslog/cron/runtime polish issues where they still matter
## 2026-04-02 — Phase 10.1: added a real `fruix system` command
Completed work:
- started Optional Phase 10 with the first user-facing Fruix system-management command surface
- wrote the subphase report:
- `docs/reports/phase10-fruix-system-command-freebsd.md`
- added a real CLI wrapper:
- `bin/fruix`
- added the corresponding Guile entry point:
- `scripts/fruix.scm`
- added a dedicated validation harness:
- `tests/system/run-phase10-fruix-system-command.sh`
What the new command does:
- exposes declarative FreeBSD system artifact generation through a Fruix CLI instead of only phase-specific harness scripts
- currently supports:
- `fruix system build OS-FILE`
- `fruix system image OS-FILE`
- `fruix system rootfs OS-FILE ROOTFS-DIR`
- currently supports options:
- `--system NAME`
- `--store DIR`
- `--disk-capacity SIZE`
- `--rootfs DIR`
- `--help`
- loads an operating-system object from a Scheme file, validates it, and dispatches to:
- `materialize-operating-system`
- `materialize-rootfs`
- `materialize-bhyve-image`
- emits machine-readable `key=value` metadata for the produced artifacts
Important findings:
- the project already had the core declarative system machinery by the end of Phase 9; the missing piece here was a direct Fruix operator interface to that machinery
- a small dedicated wrapper in this repo is currently the most practical way to expose that functionality without waiting for deeper upstream command-framework integration
- keeping the command aligned with the already validated local FreeBSD Guile / Fibers / Shepherd toolchain avoids introducing a second, divergent runtime path
Validation:
- `tests/system/run-phase10-fruix-system-command.sh` passes
- passing run workdir:
- `/tmp/phase10-fruix-cmd-1775117490`
- the test validated that:
- `fruix system build` materializes a closure under `/frx/store/*-fruix-system-fruix-freebsd`
- the produced closure contains the activation path
- `fruix system image` materializes a disk image under `/frx/store/*-fruix-bhyve-image-fruix-freebsd/disk.img`
- the command returns expected metadata fields for both actions
Current assessment:
- Fruix now has a real user-facing system command in this repo, which is a concrete step from “prototype scripts” toward “OS tooling”
- this does not replace all earlier harnesses yet, but it establishes `fruix system` as the new canonical interface for system closure/rootfs/image materialization work
- system/image creation still typically requires `sudo` because the command writes into `/frx/store` and uses privileged image-building operations
Next recommended step:
1. continue Phase 10 by making more of the existing system workflows call `bin/fruix` directly instead of bespoke phase scripts
2. reduce the current runtime compatibility shims for locally built Guile / Shepherd prefixes and move toward a more native store-path-aware Fruix runtime arrangement
3. consider adding the next operator-facing subcommand on top of the now-working image path, such as a `vm`/deploy-oriented flow for the active XCP-ng workflow

View File

@@ -0,0 +1,149 @@
# Phase 10.1: Add a real `fruix system` command for FreeBSD system artifacts
Date: 2026-04-02
## Goal
Start Optional Phase 10 by replacing one of the remaining prototype-only workflows with a real user-facing Fruix command.
The concrete target for this subphase was:
- expose the declarative FreeBSD operating-system machinery through a true `fruix` CLI entry point,
- stop relying solely on ad hoc phase-specific harness scripts for closure/image materialization,
- and validate that the new command can build the existing declarative system outputs.
## Result
A minimal user-facing `fruix` command now exists in this repository.
New entry points:
- `bin/fruix`
- `scripts/fruix.scm`
Supported commands in this first cut:
- `fruix system build OS-FILE`
- `fruix system image OS-FILE`
- `fruix system rootfs OS-FILE ROOTFS-DIR`
Supported options:
- `--system NAME`
- `--store DIR`
- `--disk-capacity SIZE`
- `--rootfs DIR`
- `--help`
The command loads a declarative operating-system definition from a Scheme file, resolves the selected operating-system binding, and then calls the existing Fruix FreeBSD system materializers directly:
- `materialize-operating-system`
- `materialize-rootfs`
- `materialize-bhyve-image`
The command prints machine-readable `key=value` metadata for the produced artifacts.
## Why this matters
Up through Phase 9, the project had already achieved a real booted Fruix guest, but the operator-facing path for producing those artifacts still lived mostly in phase-specific test harnesses such as:
- `tests/system/run-phase7-system-closure.sh`
- `tests/system/run-phase8-system-image.sh`
- `tests/system/run-phase9-xcpng-boot.sh`
Those were valuable validation tools, but they were not yet a real Fruix interface.
This subphase moves the project one step closer to a genuine OS workflow by making system artifact generation available through a Fruix CLI surface.
## Implementation details
### `bin/fruix`
Added a shell wrapper that:
- locates the repository root,
- locates the Guile and Guile-extra validation prefixes already used elsewhere in the project,
- ensures the required local Guile/Fibers/Shepherd pieces exist,
- prepares `GUILE_LOAD_PATH` for:
- `modules/`
- `~/repos/guix`
- and runs the Guile entry point.
This keeps the new command aligned with the currently validated local FreeBSD Guile toolchain rather than inventing a separate runtime path.
### `scripts/fruix.scm`
Added the actual CLI implementation.
It currently:
- parses `fruix system ...` actions,
- supports `--help`,
- loads an operating-system definition file,
- resolves the selected OS variable via `--system NAME` or a small fallback list of conventional names,
- validates the operating-system object,
- dispatches to the existing FreeBSD system materializers,
- and emits stable `key=value` metadata.
### `tests/system/run-phase10-fruix-system-command.sh`
Added a dedicated validation harness for the new command.
The test verifies that:
1. `fruix system build` materializes a closure under `/frx/store`
2. the returned closure contains the generated activation path
3. `fruix system image` materializes a disk image under `/frx/store`
4. the command emits the expected metadata fields for both operations
## Validation
Successful validation run:
- `PASS phase10-fruix-system-command`
- workdir: `/tmp/phase10-fruix-cmd-1775117490`
The new command successfully produced both:
- a Fruix system closure path under `/frx/store/*-fruix-system-fruix-freebsd`
- a Fruix image path under `/frx/store/*-fruix-bhyve-image-fruix-freebsd/disk.img`
Example usage:
```sh
sudo env HOME="$HOME" ./bin/fruix system build \
tests/system/phase7-minimal-operating-system.scm \
--system phase7-operating-system
```
```sh
sudo env HOME="$HOME" ./bin/fruix system image \
tests/system/phase7-minimal-operating-system.scm \
--system phase7-operating-system \
--disk-capacity 5g
```
## Current limitations
This is intentionally a first Phase 10 step, not the final Fruix command surface.
Notable current limitations:
- system/image materialization still expects the currently validated local Guile/Shepherd build prefixes
- writing into `/frx/store` and building disk images still typically requires `sudo`
- the command currently targets only the FreeBSD system prototype path already implemented in `modules/fruix/system/freebsd.scm`
- it does not yet integrate with the larger upstream Guix command framework; it is a Fruix-native CLI wrapper in this repo
## Assessment
This subphase successfully replaces an important transitional layer with a more OS-like Fruix interface.
The project can now say not only that it can declaratively define and boot a Fruix FreeBSD system, but also that it has a direct user-facing command for materializing the resulting system artifacts.
## Recommended next step
Continue Phase 10 by reducing another transitional seam behind `fruix system`, most likely one of:
1. add a `fruix system vm` / deploy-oriented flow on top of the now-working image path
2. replace the current compatibility symlink handling for locally built Guile/Shepherd prefixes with a more native store-path-aware runtime arrangement
3. factor the phase-specific system test harnesses to call `bin/fruix` directly where practical, so the command becomes the canonical operator path rather than just an additional wrapper

217
scripts/fruix.scm Normal file
View File

@@ -0,0 +1,217 @@
#!/tmp/guile-freebsd-validate-install/bin/guile -s
!#
(use-modules (fruix system freebsd)
(ice-9 format)
(ice-9 match)
(srfi srfi-1)
(srfi srfi-13))
(define (usage code)
(format (if (= code 0) #t (current-error-port))
"Usage: fruix system ACTION OS-FILE [OPTIONS]\n\
\n\
Actions:\n\
build Materialize the Fruix system closure in /frx/store.\n\
image Materialize the Fruix disk image in /frx/store.\n\
rootfs Materialize a rootfs tree at --rootfs DIR or ROOTFS-DIR.\n\
\n\
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' (example: 30g).\n\
--rootfs DIR Rootfs target for 'rootfs'.\n\
--help Show this help.\n")
(exit code))
(define (option-value arg prefix)
(and (string-prefix? prefix arg)
(substring arg (string-length prefix))))
(define (stringify value)
(cond ((string? value) value)
((symbol? value) (symbol->string value))
((number? value) (number->string value))
((boolean? value) (if value "true" "false"))
(else (call-with-output-string (lambda (port) (write value port))))))
(define (emit-metadata fields)
(for-each (lambda (field)
(format #t "~a=~a~%" (car field) (stringify (cdr field))))
fields))
(define (lookup-bound-value module symbol)
(let ((var (module-variable module symbol)))
(and var (variable-ref var))))
(define candidate-operating-system-symbols
'(operating-system
phase10-operating-system
phase9-operating-system
phase8-operating-system
phase7-operating-system
default-operating-system
os))
(define (resolve-operating-system-symbol module requested)
(or requested
(find (lambda (symbol)
(let ((value (lookup-bound-value module symbol)))
(and value (operating-system? value))))
candidate-operating-system-symbols)
(error "could not infer operating-system variable; use --system NAME")))
(define (load-operating-system-from-file file requested-symbol)
(unless (file-exists? file)
(error "operating-system file does not exist" file))
(primitive-load file)
(let* ((module (current-module))
(symbol (resolve-operating-system-symbol module requested-symbol))
(value (lookup-bound-value module symbol)))
(unless (and value (operating-system? value))
(error "resolved variable is not an operating-system" symbol))
(validate-operating-system value)
(values value symbol)))
(define (parse-arguments argv)
(match argv
((_)
(usage 1))
((_ "--help")
(usage 0))
((_ "help")
(usage 0))
((_ "system" "--help")
(usage 0))
((_ "system" action . rest)
(let loop ((args rest)
(positional '())
(system-name #f)
(store-dir "/frx/store")
(disk-capacity #f)
(rootfs #f))
(match args
(()
(let ((positional (reverse positional)))
`((action . ,action)
(positional . ,positional)
(system-name . ,system-name)
(store-dir . ,store-dir)
(disk-capacity . ,disk-capacity)
(rootfs . ,rootfs))))
(("--help")
(usage 0))
(((? (lambda (arg) (string-prefix? "--system=" arg)) arg) . tail)
(loop tail positional (option-value arg "--system=") store-dir disk-capacity rootfs))
(("--system" value . tail)
(loop tail positional value store-dir disk-capacity rootfs))
(((? (lambda (arg) (string-prefix? "--store=" arg)) arg) . tail)
(loop tail positional system-name (option-value arg "--store=") disk-capacity rootfs))
(("--store" value . tail)
(loop tail positional system-name value disk-capacity rootfs))
(((? (lambda (arg) (string-prefix? "--disk-capacity=" arg)) arg) . tail)
(loop tail positional system-name store-dir (option-value arg "--disk-capacity=") rootfs))
(("--disk-capacity" value . tail)
(loop tail positional system-name store-dir value rootfs))
(((? (lambda (arg) (string-prefix? "--rootfs=" arg)) arg) . tail)
(loop tail positional system-name store-dir disk-capacity (option-value arg "--rootfs=")))
(("--rootfs" value . tail)
(loop tail positional system-name store-dir disk-capacity value))
(((? (lambda (arg) (string-prefix? "--" arg)) arg) . _)
(error "unknown option" arg))
((arg . tail)
(loop tail (cons arg positional) system-name store-dir disk-capacity rootfs)))))
((_ . _)
(usage 1))))
(define (main argv)
(let* ((parsed (parse-arguments argv))
(action (assoc-ref parsed 'action))
(positional (assoc-ref parsed 'positional))
(store-dir (assoc-ref parsed 'store-dir))
(disk-capacity (assoc-ref parsed 'disk-capacity))
(rootfs-opt (assoc-ref parsed 'rootfs))
(system-name (assoc-ref parsed 'system-name))
(requested-symbol (and system-name (string->symbol system-name))))
(cond
((member action '("build" "image" "rootfs")) #t)
(else (error "unknown system action" action)))
(let* ((os-file (match positional
((file . _) file)
(() (error "missing operating-system file argument"))))
(rootfs (or rootfs-opt
(and (string=? action "rootfs")
(match positional
((_ dir) dir)
((_ _ dir . _) dir)
(_ #f))))))
(call-with-values
(lambda ()
(load-operating-system-from-file os-file requested-symbol))
(lambda (os resolved-symbol)
(let* ((guile-prefix (or (getenv "GUILE_PREFIX") "/tmp/guile-freebsd-validate-install"))
(guile-extra-prefix (or (getenv "GUILE_EXTRA_PREFIX") "/tmp/guile-gnutls-freebsd-validate-install"))
(shepherd-prefix (or (getenv "SHEPHERD_PREFIX") "/tmp/shepherd-freebsd-validate-install")))
(cond
((string=? action "build")
(let* ((result (materialize-operating-system os
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix))
(closure-path (assoc-ref result 'closure-path))
(generated-files (assoc-ref result 'generated-files))
(references (assoc-ref result 'references)))
(emit-metadata
`((action . "build")
(os_file . ,os-file)
(system_variable . ,resolved-symbol)
(store_dir . ,store-dir)
(closure_path . ,closure-path)
(kernel_store . ,(assoc-ref result 'kernel-store))
(bootloader_store . ,(assoc-ref result 'bootloader-store))
(guile_store . ,(assoc-ref result 'guile-store))
(guile_extra_store . ,(assoc-ref result 'guile-extra-store))
(shepherd_store . ,(assoc-ref result 'shepherd-store))
(generated_file_count . ,(length generated-files))
(reference_count . ,(length references))))))
((string=? action "rootfs")
(unless rootfs
(error "rootfs action requires ROOTFS-DIR or --rootfs DIR"))
(let ((result (materialize-rootfs os rootfs
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix)))
(emit-metadata
`((action . "rootfs")
(os_file . ,os-file)
(system_variable . ,resolved-symbol)
(store_dir . ,store-dir)
(rootfs . ,(assoc-ref result 'rootfs))
(closure_path . ,(assoc-ref result 'closure-path))
(ready_marker . ,(assoc-ref result 'ready-marker))
(rc_script . ,(assoc-ref result 'rc-script))))))
((string=? action "image")
(let* ((result (materialize-bhyve-image os
#:store-dir store-dir
#:guile-prefix guile-prefix
#:guile-extra-prefix guile-extra-prefix
#:shepherd-prefix shepherd-prefix
#:disk-capacity disk-capacity))
(image-spec (assoc-ref result 'image-spec))
(store-items (assoc-ref result 'store-items)))
(emit-metadata
`((action . "image")
(os_file . ,os-file)
(system_variable . ,resolved-symbol)
(store_dir . ,store-dir)
(disk_capacity . ,(assoc-ref image-spec 'disk-capacity))
(image_store_path . ,(assoc-ref result 'image-store-path))
(disk_image . ,(assoc-ref result 'disk-image))
(esp_image . ,(assoc-ref result 'esp-image))
(root_image . ,(assoc-ref result 'root-image))
(closure_path . ,(assoc-ref result 'closure-path))
(store_item_count . ,(length store-items)))))))))))))
(main (command-line))

View File

@@ -0,0 +1,102 @@
#!/bin/sh
set -eu
project_root=${PROJECT_ROOT:-$(pwd)}
os_file=${OS_FILE:-$project_root/tests/system/phase7-minimal-operating-system.scm}
system_name=${SYSTEM_NAME:-phase7-operating-system}
store_dir=${STORE_DIR:-/frx/store}
disk_capacity=${DISK_CAPACITY:-5g}
metadata_target=${METADATA_OUT:-}
cleanup=0
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-phase10-system-command.XXXXXX)
cleanup=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup=0
fi
cleanup_workdir() {
if [ "$cleanup" -eq 1 ]; then
rm -rf "$workdir" 2>/dev/null || sudo rm -rf "$workdir"
fi
}
trap cleanup_workdir EXIT INT TERM
build_out=$workdir/build.txt
image_out=$workdir/image.txt
metadata_file=$workdir/phase10-fruix-system-command-metadata.txt
sudo env \
HOME="$HOME" \
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}" \
"$project_root/bin/fruix" system build "$os_file" --system "$system_name" --store "$store_dir" >"$build_out"
closure_path=$(sed -n 's/^closure_path=//p' "$build_out")
generated_file_count=$(sed -n 's/^generated_file_count=//p' "$build_out")
reference_count=$(sed -n 's/^reference_count=//p' "$build_out")
case "$closure_path" in
/frx/store/*-fruix-system-fruix-freebsd) : ;;
*) echo "unexpected closure path: $closure_path" >&2; exit 1 ;;
esac
[ -x "$closure_path/activate" ] || { echo "missing activate script in closure" >&2; exit 1; }
[ -n "$generated_file_count" ] || { echo "missing generated_file_count" >&2; exit 1; }
[ -n "$reference_count" ] || { echo "missing reference_count" >&2; exit 1; }
sudo env \
HOME="$HOME" \
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}" \
"$project_root/bin/fruix" system image "$os_file" --system "$system_name" --store "$store_dir" --disk-capacity "$disk_capacity" >"$image_out"
image_store_path=$(sed -n 's/^image_store_path=//p' "$image_out")
disk_image=$(sed -n 's/^disk_image=//p' "$image_out")
disk_capacity_reported=$(sed -n 's/^disk_capacity=//p' "$image_out")
store_item_count=$(sed -n 's/^store_item_count=//p' "$image_out")
case "$image_store_path" in
/frx/store/*-fruix-bhyve-image-fruix-freebsd) : ;;
*) echo "unexpected image store path: $image_store_path" >&2; exit 1 ;;
esac
case "$disk_image" in
/frx/store/*-fruix-bhyve-image-fruix-freebsd/disk.img) : ;;
*) echo "unexpected disk image path: $disk_image" >&2; exit 1 ;;
esac
[ -f "$disk_image" ] || { echo "missing disk image" >&2; exit 1; }
[ -n "$disk_capacity_reported" ] || { echo "missing disk capacity report" >&2; exit 1; }
[ -n "$store_item_count" ] || { echo "missing store item count" >&2; exit 1; }
cat >"$metadata_file" <<EOF
workdir=$workdir
os_file=$os_file
system_name=$system_name
store_dir=$store_dir
closure_path=$closure_path
generated_file_count=$generated_file_count
reference_count=$reference_count
image_store_path=$image_store_path
disk_image=$disk_image
disk_capacity=$disk_capacity_reported
store_item_count=$store_item_count
EOF
if [ -n "$metadata_target" ]; then
cp "$metadata_file" "$metadata_target"
fi
printf 'PASS phase10-fruix-system-command\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