feat(install): add versioned setup and system contracts
--- Build: pass | Tests: pass — Tests 2000 passed (2000)
This commit is contained in:
parent
d5182ec480
commit
975f37f895
16 changed files with 969 additions and 59 deletions
|
|
@ -17,6 +17,9 @@ here for implementation reference:
|
|||
|
||||
| Tier | Field | Required? | Default |
|
||||
| ----------- | -------------------- | --------- | ----------------------------------------------- |
|
||||
| Prefilled | `SETUP_SCHEMA_VERSION` | no | `1` |
|
||||
| Prefilled | `ISO_RELEASE` | no | ISO build release, e.g. `v0.10.0` |
|
||||
| Optional | `ISO_GIT_COMMIT` | no | exact ISO build commit |
|
||||
| Required | `OPENROUTER_API_KEY` | yes | — |
|
||||
| Required | `TELEGRAM_BOT_TOKEN` | yes | — |
|
||||
| Required | `TELEGRAM_ADMIN_ID` | yes | — |
|
||||
|
|
@ -190,39 +193,45 @@ an unnecessary new interface. A natural follow-up is to surface
|
|||
- keeps the legacy `operators` table password in sync for API/basic-auth compatibility
|
||||
- if the Better Auth user already exists, keeps legacy/API password in sync and prints a clear note that dashboard password may need a manual reset
|
||||
|
||||
**Cleanup still worth doing later:**
|
||||
|
||||
- collapse the operator-facing script and setup-step path onto one shared implementation
|
||||
- keep the legacy `operators` table in sync from that shared implementation
|
||||
- document password rotation policy explicitly once dashboard reset flow is frozen
|
||||
**Implementation status:** done — both entrypoints now delegate to one shared backend.
|
||||
|
||||
---
|
||||
|
||||
### Task 5 — ISO seed delivery mechanism validation
|
||||
### Task 5 — ISO seed delivery mechanism validation ✅
|
||||
|
||||
**Owner:** _unclaimed_ (Codex required — needs ISO repo access)
|
||||
**Type:** deploy / decision
|
||||
**Owner:** Codex
|
||||
**Type:** decision
|
||||
|
||||
Validates which of the three candidate delivery mechanisms actually
|
||||
works on the target ISO build:
|
||||
**Resolved V1 mechanism:**
|
||||
|
||||
- A. Dedicated writable seed partition on the flashed USB
|
||||
- B. Separate second USB or removable media carrying `setup.txt`
|
||||
- C. Unattended-install file on the install media itself
|
||||
- the flashed USB exposes a writable FAT32 config surface
|
||||
- operator-edited files on that surface are:
|
||||
- `setup.txt`
|
||||
- `system.env`
|
||||
|
||||
Output: a recommendation with evidence (boot logs, mount behavior,
|
||||
operator UX in the flashing tool of choice). The chosen mechanism
|
||||
becomes the V1 commitment; the others get banked as alternatives in
|
||||
the lifted install docs.
|
||||
**Why this won:**
|
||||
|
||||
- cross-platform writable from Windows, macOS, Linux, FreeBSD
|
||||
- no second USB required
|
||||
- works naturally with inspect output and backfill
|
||||
- matches the ISO repo’s existing editable-config-on-removable-media direction
|
||||
|
||||
**Banked alternatives, not primary:**
|
||||
|
||||
- separate second USB
|
||||
- unattended-install embedded file
|
||||
|
||||
**Inspect-mode note (V1):**
|
||||
|
||||
- the primary inspect path is a native shell collector:
|
||||
`scripts/inspect-system.sh`
|
||||
- it writes `system.txt`, `system.env`, `suggested-setup.txt`, and
|
||||
- it writes `system.txt`, `system.env`, `inspect-facts.env`, `suggested-setup.txt`, and
|
||||
raw command artifacts
|
||||
- `system.env` now carries `SYSTEM_SCHEMA_VERSION=1` plus hardware contract fields
|
||||
- it can optionally backfill blank `setup.txt` install/storage fields
|
||||
via `--apply-setup <path>`
|
||||
- it can optionally backfill blank `system.env` hardware fields via
|
||||
`--apply-system-env <path>`
|
||||
- this is deliberate so inspect works before Node/runtime concerns
|
||||
are solved on the ISO
|
||||
- a richer future live/interactive mode may reuse the same artifacts,
|
||||
|
|
@ -256,8 +265,8 @@ follow-up to mark complete.
|
|||
**Depends on:** 1, 2, 3, 4, 5
|
||||
**Status:** skeleton landed at
|
||||
[`docs/public/install/first-boot.md`](../public/install/first-boot.md),
|
||||
linked from `docs/public/install/index.md`. Two TBD blocks remain:
|
||||
"Where setup.txt lives" (waits on task 5) and "Troubleshooting"
|
||||
linked from `docs/public/install/index.md`. One TBD block remains:
|
||||
"Troubleshooting"
|
||||
(waits on real failure traces from the ISO build).
|
||||
|
||||
Walkthrough at `docs/public/install/first-boot.md`. Covers:
|
||||
|
|
@ -296,7 +305,7 @@ implementation.
|
|||
|
||||
---
|
||||
|
||||
## Adopt Mode (V1.1) — placeholder
|
||||
## Upgrade And Rescue (V1.1) — design contract
|
||||
|
||||
Reflashing the same machine should not wipe data. The first-boot
|
||||
flow we shipped covers fresh installs only. The follow-on slice
|
||||
|
|
@ -320,12 +329,45 @@ already agreed (recorded in handoff Round 5) are:
|
|||
- Telegram identity changes require an explicit flag, never silent.
|
||||
- `INSTALL_MODE=auto` is the default; `fresh`, `upgrade`, and `rescue` are
|
||||
explicit overrides.
|
||||
- Install-fingerprint check gates upgrade — proposed primitive: a
|
||||
marker file at `/var/db/clawdie/install-fingerprint.json`
|
||||
containing install UUID, original `ASSISTANT_NAME`, `HOSTNAME`,
|
||||
hash of `TELEGRAM_ADMIN_ID`, install timestamp.
|
||||
- Install-fingerprint check gates upgrade — canonical primitive is
|
||||
ZFS user properties on the root dataset, not a sidecar file.
|
||||
|
||||
Tentative task split (subject to Codex's design doc):
|
||||
Fingerprint source of truth:
|
||||
|
||||
- dataset: `<ZFS_POOL>/<ZFS_PREFIX>`
|
||||
- properties:
|
||||
- `org.clawdie:install-uuid`
|
||||
- `org.clawdie:setup-schema`
|
||||
- `org.clawdie:system-schema`
|
||||
- `org.clawdie:iso-release`
|
||||
- `org.clawdie:iso-commit`
|
||||
- `org.clawdie:installed-at`
|
||||
- `org.clawdie:last-upgraded-at`
|
||||
- `org.clawdie:assistant-name`
|
||||
- `org.clawdie:hostname`
|
||||
- `org.clawdie:tenant-id`
|
||||
- `org.clawdie:telegram-admin-hash`
|
||||
- `org.clawdie:install-mode`
|
||||
- `org.clawdie:zfs-layout`
|
||||
- `org.clawdie:zfs-data-disks`
|
||||
- `org.clawdie:zfs-hot-spares`
|
||||
|
||||
Why ZFS metadata wins:
|
||||
|
||||
- lives with the data
|
||||
- survives reflash/reinstall
|
||||
- survives pool export/import and host migration
|
||||
- inspectable with native `zfs get`
|
||||
- avoids inventing another persistent state file
|
||||
|
||||
Upgrade comparison order:
|
||||
|
||||
1. persisted ZFS dataset metadata
|
||||
2. `setup.txt` operator intent
|
||||
3. `system.env` hardware intent
|
||||
4. live detected host state
|
||||
|
||||
Task split:
|
||||
|
||||
1. **Upgrade/rescue design doc** (Codex) — overwrite rules per field, the
|
||||
fingerprint schema, upgrade vs restore separation.
|
||||
|
|
@ -333,7 +375,7 @@ Tentative task split (subject to Codex's design doc):
|
|||
the multi-signal checklist (existing `.env`, controlplane DB
|
||||
reachable, ZFS datasets/jails, platform service/user) and
|
||||
returns a typed `DetectionResult`.
|
||||
3. **Adopt-mode wiring** — branches in `setup/install.ts` based
|
||||
3. **Upgrade/rescue wiring** — branches in `setup/install.ts` based
|
||||
on detection + `INSTALL_MODE`. Honors the per-field overwrite
|
||||
rules from the design doc.
|
||||
4. **Restore path** (separate slice) — for new hardware /
|
||||
|
|
@ -352,3 +394,19 @@ Field freeze contract (proposed, per Round 5):
|
|||
| `OPERATOR_EMAIL` / `OPERATOR_PASSWORD` | ignored in upgrade (dashboard owns rotation) |
|
||||
| `SSH_AUTHORIZED_KEY` | rotatable (additive — appends, doesn't replace) |
|
||||
| `CLAWDIE_USER_PASSWORD` | rotatable if explicitly set; warn loudly |
|
||||
|
||||
Security checks for upgrade:
|
||||
|
||||
- `SETUP_SCHEMA_VERSION` must be supported
|
||||
- `SYSTEM_SCHEMA_VERSION` must be supported
|
||||
- `ISO_RELEASE` / `ISO_GIT_COMMIT` are recorded and surfaced, not used as sole trust signals
|
||||
- root dataset must match declared `ZFS_POOL` + `ZFS_PREFIX`
|
||||
- `ASSISTANT_NAME` / `HOSTNAME` / Telegram admin mismatch blocks `upgrade`
|
||||
- `rescue` may proceed with mismatch warnings, but does not silently overwrite identity
|
||||
|
||||
`.env` versus `setup.txt`:
|
||||
|
||||
- they are not expected to be identical
|
||||
- `setup.txt` is bootstrap/operator intent
|
||||
- `.env` is runtime state plus derived/internal values
|
||||
- upgrade compares only the declared overlap set, not full-file equality
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ title: 'First Boot (V1)'
|
|||
description: Edit one file, flash a USB, boot — get to a working assistant with minimal interaction.
|
||||
---
|
||||
|
||||
> **Status:** Draft skeleton. The seed-delivery mechanism (where
|
||||
> `setup.txt` lives on the install media) is still being validated
|
||||
> against the ISO build — see "Where setup.txt lives" below. The
|
||||
> **Status:** Active V1 contract. The operator-facing flow is:
|
||||
> edit `setup.txt`, optionally let inspect fill `system.env`, boot.
|
||||
> The exact first-boot file locations are now frozen below. The
|
||||
> rest of this guide is the operator-facing contract for the V1
|
||||
> first-boot flow described in
|
||||
> [`docs/internal/ISO-FIRST-BOOT-SECRETS-HANDOFF.md`](https://codeberg.org/Clawdie/Clawdie-AI/src/branch/multitenant/docs/internal/ISO-FIRST-BOOT-SECRETS-HANDOFF.md)
|
||||
|
|
@ -19,6 +19,19 @@ installer reads your file on first start. No interactive
|
|||
out-of-the-box wizard, no autogenerated secrets you have to write
|
||||
down later.
|
||||
|
||||
The first-boot setup file is versioned. If you do not set these
|
||||
yourself, the installer fills safe defaults:
|
||||
|
||||
```text
|
||||
SETUP_SCHEMA_VERSION=1
|
||||
ISO_RELEASE=v0.10.0
|
||||
ISO_GIT_COMMIT=
|
||||
```
|
||||
|
||||
- `SETUP_SCHEMA_VERSION` tracks the file format the installer understands
|
||||
- `ISO_RELEASE` records which Clawdie release the install media came from
|
||||
- `ISO_GIT_COMMIT` is optional exact build provenance
|
||||
|
||||
## What you need
|
||||
|
||||
- The Clawdie ISO ([releases](https://codeberg.org/Clawdie/Clawdie-ISO/releases))
|
||||
|
|
@ -57,10 +70,35 @@ first-boot setup file:
|
|||
That only fills blank or missing fields. It does not overwrite values
|
||||
you already chose explicitly.
|
||||
|
||||
To populate the hardware contract file directly:
|
||||
|
||||
```bash
|
||||
./scripts/inspect-system.sh \
|
||||
--output /path/to/writable/media \
|
||||
--apply-system-env /path/to/writable/media/system.env
|
||||
```
|
||||
|
||||
That fills blank or missing hardware fields in `system.env` such as:
|
||||
|
||||
- `SYSTEM_SCHEMA_VERSION`
|
||||
- `NETWORK_EXTERNAL_IF`
|
||||
- `NETWORK_INTERNAL_IF`
|
||||
- `TAILSCALE_IF`
|
||||
- `ZFS_POOL`
|
||||
- `ZFS_LAYOUT`
|
||||
- `ZFS_DATA_DISKS`
|
||||
- `ZFS_HOT_SPARES`
|
||||
- `ZFS_DISKS`
|
||||
- `ZFS_SPARE_DISKS`
|
||||
- `ZFS_PREFIX`
|
||||
- `GPU_DEVICE`
|
||||
- `SND_DEVICE`
|
||||
|
||||
That writes:
|
||||
|
||||
- `system.txt` — a human summary
|
||||
- `system.env` — machine-readable facts for future installer/UI use
|
||||
- `system.env` — hardware contract values the installer can use directly
|
||||
- `inspect-facts.env` — richer machine-readable inspect metadata
|
||||
- `suggested-setup.txt` — lines you can copy back into `setup.txt`
|
||||
- raw artifacts such as `dmesg.txt`, `ifconfig.txt`, `zpool-status.txt`,
|
||||
`zfs-list.txt`, and `pf-interfaces.txt`
|
||||
|
|
@ -193,6 +231,31 @@ ZFS_HOT_SPARES=1
|
|||
The installer derives full dataset paths from these values. You do
|
||||
not type raw dataset paths into the first-boot setup file.
|
||||
|
||||
These storage declarations are also part of the long-term upgrade
|
||||
contract. The installer can later compare:
|
||||
|
||||
- `setup.txt` operator intent
|
||||
- `system.env` hardware intent
|
||||
- persisted ZFS dataset metadata
|
||||
|
||||
For advanced operators, that metadata lives as ZFS user properties on
|
||||
the root dataset `<ZFS_POOL>/<ZFS_PREFIX>`, using keys such as:
|
||||
|
||||
- `org.clawdie:install-uuid`
|
||||
- `org.clawdie:setup-schema`
|
||||
- `org.clawdie:system-schema`
|
||||
- `org.clawdie:iso-release`
|
||||
- `org.clawdie:iso-commit`
|
||||
- `org.clawdie:assistant-name`
|
||||
- `org.clawdie:hostname`
|
||||
- `org.clawdie:telegram-admin-hash`
|
||||
- `org.clawdie:zfs-layout`
|
||||
- `org.clawdie:zfs-data-disks`
|
||||
- `org.clawdie:zfs-hot-spares`
|
||||
|
||||
This is deliberate: the upgrade fingerprint lives with the data,
|
||||
survives reflash, and can be inspected with native `zfs get`.
|
||||
|
||||
If you ran inspect first, it will already have written suggested
|
||||
storage lines based on:
|
||||
|
||||
|
|
@ -238,15 +301,38 @@ locked by design. Use `sudo` from the service user.
|
|||
|
||||
## Where The First-Boot Setup Lives
|
||||
|
||||
> **TBD:** the seed-delivery mechanism (writable partition on the
|
||||
> flashed USB vs separate media vs unattended-install file) is
|
||||
> being validated against the ISO build (task 5). This section
|
||||
> will name the exact mechanism and walk through the operator
|
||||
> steps once that resolves.
|
||||
>
|
||||
> Until then, the contract is: after you flash the USB, you can
|
||||
> reach `setup.txt` from your laptop, edit it, and the installer
|
||||
> picks it up on first boot.
|
||||
The V1 decision is:
|
||||
|
||||
- the flashed USB exposes a writable FAT32 config surface
|
||||
- the operator edits two files there:
|
||||
- `setup.txt`
|
||||
- `system.env`
|
||||
- the installer reads those files on first boot
|
||||
|
||||
Why this path won:
|
||||
|
||||
- it is writable from Windows, macOS, Linux, and FreeBSD
|
||||
- it supports long API keys without console typing
|
||||
- it works with the inspect loop naturally
|
||||
- it matches the ISO repo’s existing “editable config on removable media”
|
||||
direction without requiring a second USB
|
||||
|
||||
Operator flow:
|
||||
|
||||
1. Flash the USB.
|
||||
2. Reinsert it on your normal computer.
|
||||
3. Open the writable config surface.
|
||||
4. Edit `setup.txt`.
|
||||
5. Optionally leave `system.env` blank and let inspect populate it.
|
||||
6. Boot the target machine from that USB.
|
||||
|
||||
The installer treats:
|
||||
|
||||
- `setup.txt` as operator intent
|
||||
- `system.env` as hardware intent
|
||||
|
||||
Both files are versioned and can be compared against persisted ZFS
|
||||
metadata during later upgrade and rescue flows.
|
||||
|
||||
## Boot
|
||||
|
||||
|
|
@ -308,13 +394,18 @@ the install is healthy.
|
|||
> retry). This section fills in once task 5 lands and we have
|
||||
> real failure traces from the ISO build.
|
||||
|
||||
## Reflashing later (V1.1, planned)
|
||||
## Reflashing later
|
||||
|
||||
This page covers fresh installs. Reflashing the same machine to
|
||||
upgrade Clawdie *without* losing data (upgrade mode) is on the V1.1
|
||||
roadmap — `INSTALL_MODE=auto|fresh|upgrade|rescue` in `setup.txt`, with
|
||||
upgrade as the safe in-place path. Until that lands, the install
|
||||
flow assumes fresh; back up before reflashing.
|
||||
This page still focuses on fresh installs, but the first pieces of the
|
||||
reflash path now exist:
|
||||
|
||||
- `INSTALL_MODE=auto|fresh|upgrade|rescue` is part of the contract
|
||||
- inspect can suggest and backfill `upgrade`-oriented storage values
|
||||
- `setup.txt` and `system.env` are both versioned
|
||||
|
||||
What is still being hardened is the full upgrade fingerprint and
|
||||
overwrite-policy logic. Until that lands completely, treat reflash
|
||||
upgrade as an advanced path and keep backups before you test it.
|
||||
|
||||
## Related
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ description: Operator install paths for Clawdie on FreeBSD.
|
|||
|
||||
## Recommended path
|
||||
|
||||
The V1 model: edit the first-boot setup (`setup.txt`), flash a USB, boot. No interactive
|
||||
out-of-the-box wizard, no autogenerated secrets.
|
||||
The V1 model: edit the first-boot setup (`setup.txt`), optionally let
|
||||
inspect populate the hardware contract (`system.env`), flash a USB,
|
||||
boot. No interactive out-of-the-box wizard, no autogenerated secrets.
|
||||
|
||||
- [First boot (V1)](./first-boot/) — the four lines, optional
|
||||
fields, optional inspect step, and what to expect.
|
||||
fields, versioned setup files, optional inspect step, and what to expect.
|
||||
- [ISO install](./iso/) — image selection, USB writing, rebuild path.
|
||||
|
||||
## Existing-host install
|
||||
|
|
|
|||
|
|
@ -118,6 +118,9 @@ explicit fallback when the first-boot setup is absent or invalid; it sources
|
|||
locales from FreeBSD itself, so any installed locale can be
|
||||
selected (`en_US.UTF-8`, `zh_CN.UTF-8`, etc.) and is applied
|
||||
consistently.
|
||||
`setup.txt` is now a versioned operator-intent contract, and
|
||||
`system.env` is the matching hardware-intent contract. Inspect can
|
||||
populate both before the installer runs.
|
||||
Set `DB_RUNTIME=host` in `.env` to provision PostgreSQL directly on the host instead of a db jail; `DB_HOST` defaults to `${AGENT_SUBNET_BASE}.1` so jails can reach it. Use `DB_COMPRESSION=lz4` (default) or `DB_COMPRESSION=zstd` for ZFS compression on host datasets.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ After flashing, either:
|
|||
|
||||
- edit the first-boot setup (`setup.txt`) directly, or
|
||||
- run the optional inspect step first to collect disk/network facts
|
||||
into `system.txt` and `suggested-setup.txt`
|
||||
into `system.txt`, `system.env`, `inspect-facts.env`, and `suggested-setup.txt`
|
||||
- optionally let inspect backfill blank install/storage values into
|
||||
`setup.txt`
|
||||
- optionally let inspect backfill blank hardware fields into
|
||||
`system.env`
|
||||
|
||||
Then boot. See [First boot](./first-boot/) for the rest.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ repo_root() {
|
|||
|
||||
ROOT_DIR="$(repo_root)"
|
||||
DEFAULT_OUTPUT_DIR="$ROOT_DIR/tmp/inspect"
|
||||
SETUP_SCHEMA_VERSION=1
|
||||
SYSTEM_SCHEMA_VERSION=1
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
|
|
@ -42,6 +44,7 @@ read_setup_value() {
|
|||
|
||||
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
|
||||
APPLY_SETUP_FILE=""
|
||||
APPLY_SYSTEM_ENV_FILE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
|
|
@ -60,8 +63,16 @@ while [ $# -gt 0 ]; do
|
|||
APPLY_SETUP_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply-system-env)
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "--apply-system-env requires a system.env path" >&2
|
||||
exit 1
|
||||
fi
|
||||
APPLY_SYSTEM_ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--output <dir>] [--apply-setup <setup.txt>]" >&2
|
||||
echo "Usage: $0 [--output <dir>] [--apply-setup <setup.txt>] [--apply-system-env <system.env>]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -69,6 +80,15 @@ done
|
|||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
ISO_RELEASE="$(sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/v\1/p' "$ROOT_DIR/package.json" | head -n 1 || true)"
|
||||
if [ -z "$ISO_RELEASE" ]; then
|
||||
ISO_RELEASE="v0.0.0"
|
||||
fi
|
||||
ISO_GIT_COMMIT=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
ISO_GIT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --short=8 HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
capture() {
|
||||
local filename="$1"
|
||||
shift
|
||||
|
|
@ -90,6 +110,11 @@ capture "pf-interfaces.txt" pfctl -s Interfaces
|
|||
capture "zpool-list.txt" zpool list -H -o name,size,alloc,free,cap,frag,health
|
||||
capture "zpool-status.txt" zpool status
|
||||
capture "zfs-list.txt" zfs list -H -o name,used,avail,refer,mountpoint
|
||||
if [ -r /dev/sndstat ]; then
|
||||
cat /dev/sndstat >"$OUTPUT_DIR/sndstat.txt" 2>&1 || true
|
||||
else
|
||||
printf 'sndstat unavailable\n' >"$OUTPUT_DIR/sndstat.txt"
|
||||
fi
|
||||
|
||||
apply_if_blank() {
|
||||
local file="$1"
|
||||
|
|
@ -307,7 +332,35 @@ fi
|
|||
DATASET_PREVIEW="$(awk -F'\t' 'NF {print $1}' "$OUTPUT_DIR/zfs-list.txt" | head -n 8 | paste -sd',' -)"
|
||||
[ -z "$DATASET_PREVIEW" ] && DATASET_PREVIEW="none detected"
|
||||
|
||||
PRIMARY_EXTERNAL_IF="$(printf '%s' "$EXTERNAL_IFACES" | awk -F',' 'NF {print $1}')"
|
||||
PRIMARY_INTERNAL_IF="$(printf '%s' "$INTERNAL_IFACES" | awk -F',' 'NF {print $1}')"
|
||||
[ "$PRIMARY_EXTERNAL_IF" = "none detected" ] && PRIMARY_EXTERNAL_IF=""
|
||||
[ "$PRIMARY_INTERNAL_IF" = "none detected" ] && PRIMARY_INTERNAL_IF=""
|
||||
TAILSCALE_IF=""
|
||||
printf '%s\n' "$INTERFACES" | tr ',' '\n' | grep -qx 'tailscale0' && TAILSCALE_IF='tailscale0'
|
||||
GPU_DEVICE=""
|
||||
if [ -e /dev/drm0 ] || [ -e /dev/dri/card0 ]; then
|
||||
GPU_DEVICE="drm0"
|
||||
elif grep -Eiq '(VGA|display)' "$OUTPUT_DIR/pciconf.txt"; then
|
||||
GPU_DEVICE="auto-gpu"
|
||||
fi
|
||||
SND_DEVICE="$(grep -Eo 'pcm[0-9]+' "$OUTPUT_DIR/sndstat.txt" | head -n 1 || true)"
|
||||
|
||||
ZFS_DATA_DEVICE_LIST=""
|
||||
ZFS_SPARE_DEVICE_LIST=""
|
||||
if [ "$DISK_COUNT" -gt 0 ] && [ -n "$DISK_DEVICES" ]; then
|
||||
if [ "$SUGGESTED_ZFS_HOT_SPARES" -gt 0 ]; then
|
||||
ZFS_DATA_DEVICE_LIST="$(printf '%s\n' "$DISK_DEVICES" | tr ',' '\n' | head -n "$SUGGESTED_ZFS_DATA_DISKS" | paste -sd',' -)"
|
||||
ZFS_SPARE_DEVICE_LIST="$(printf '%s\n' "$DISK_DEVICES" | tr ',' '\n' | tail -n "$SUGGESTED_ZFS_HOT_SPARES" | paste -sd',' -)"
|
||||
else
|
||||
ZFS_DATA_DEVICE_LIST="$DISK_DEVICES"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat >"$OUTPUT_DIR/suggested-setup.txt" <<EOF
|
||||
SETUP_SCHEMA_VERSION=$SETUP_SCHEMA_VERSION
|
||||
ISO_RELEASE=$ISO_RELEASE
|
||||
ISO_GIT_COMMIT=$ISO_GIT_COMMIT
|
||||
INSTALL_MODE=$SUGGESTED_INSTALL_MODE
|
||||
ZFS_POOL=$POOL_NAME
|
||||
ZFS_LAYOUT=$SUGGESTED_ZFS_LAYOUT
|
||||
|
|
@ -315,11 +368,30 @@ ZFS_DATA_DISKS=$SUGGESTED_ZFS_DATA_DISKS
|
|||
ZFS_HOT_SPARES=$SUGGESTED_ZFS_HOT_SPARES
|
||||
EOF
|
||||
|
||||
cat >"$OUTPUT_DIR/system.env" <<EOF
|
||||
SYSTEM_SCHEMA_VERSION=$SYSTEM_SCHEMA_VERSION
|
||||
NETWORK_EXTERNAL_IF=$PRIMARY_EXTERNAL_IF
|
||||
NETWORK_INTERNAL_IF=$PRIMARY_INTERNAL_IF
|
||||
TAILSCALE_IF=$TAILSCALE_IF
|
||||
ZFS_POOL=$POOL_NAME
|
||||
ZFS_LAYOUT=$SUGGESTED_ZFS_LAYOUT
|
||||
ZFS_DATA_DISKS=$SUGGESTED_ZFS_DATA_DISKS
|
||||
ZFS_HOT_SPARES=$SUGGESTED_ZFS_HOT_SPARES
|
||||
ZFS_DISKS=$ZFS_DATA_DEVICE_LIST
|
||||
ZFS_SPARE_DISKS=$ZFS_SPARE_DEVICE_LIST
|
||||
ZFS_PREFIX=$ZFS_PREFIX_VALUE
|
||||
GPU_DEVICE=$GPU_DEVICE
|
||||
SND_DEVICE=$SND_DEVICE
|
||||
EOF
|
||||
|
||||
if [ -n "$APPLY_SETUP_FILE" ]; then
|
||||
mkdir -p "$(dirname "$APPLY_SETUP_FILE")"
|
||||
if [ ! -f "$APPLY_SETUP_FILE" ]; then
|
||||
: >"$APPLY_SETUP_FILE"
|
||||
fi
|
||||
apply_if_blank "$APPLY_SETUP_FILE" "SETUP_SCHEMA_VERSION" "$SETUP_SCHEMA_VERSION"
|
||||
apply_if_blank "$APPLY_SETUP_FILE" "ISO_RELEASE" "$ISO_RELEASE"
|
||||
apply_if_blank "$APPLY_SETUP_FILE" "ISO_GIT_COMMIT" "$ISO_GIT_COMMIT"
|
||||
apply_if_blank "$APPLY_SETUP_FILE" "INSTALL_MODE" "$SUGGESTED_INSTALL_MODE"
|
||||
apply_if_blank "$APPLY_SETUP_FILE" "ZFS_POOL" "$POOL_NAME"
|
||||
CURRENT_ZFS_LAYOUT="$(read_setup_value "$APPLY_SETUP_FILE" "ZFS_LAYOUT" 2>/dev/null || true)"
|
||||
|
|
@ -330,7 +402,30 @@ if [ -n "$APPLY_SETUP_FILE" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
cat >"$OUTPUT_DIR/system.env" <<EOF
|
||||
if [ -n "$APPLY_SYSTEM_ENV_FILE" ]; then
|
||||
mkdir -p "$(dirname "$APPLY_SYSTEM_ENV_FILE")"
|
||||
if [ ! -f "$APPLY_SYSTEM_ENV_FILE" ]; then
|
||||
: >"$APPLY_SYSTEM_ENV_FILE"
|
||||
fi
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "SYSTEM_SCHEMA_VERSION" "$SYSTEM_SCHEMA_VERSION"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "NETWORK_EXTERNAL_IF" "$PRIMARY_EXTERNAL_IF"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "NETWORK_INTERNAL_IF" "$PRIMARY_INTERNAL_IF"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "TAILSCALE_IF" "$TAILSCALE_IF"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_POOL" "$POOL_NAME"
|
||||
CURRENT_SYSTEM_ZFS_LAYOUT="$(read_setup_value "$APPLY_SYSTEM_ENV_FILE" "ZFS_LAYOUT" 2>/dev/null || true)"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_LAYOUT" "$SUGGESTED_ZFS_LAYOUT"
|
||||
if [ -z "$CURRENT_SYSTEM_ZFS_LAYOUT" ] || [ "$CURRENT_SYSTEM_ZFS_LAYOUT" = "$SUGGESTED_ZFS_LAYOUT" ]; then
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_DATA_DISKS" "$SUGGESTED_ZFS_DATA_DISKS"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_HOT_SPARES" "$SUGGESTED_ZFS_HOT_SPARES"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_DISKS" "$ZFS_DATA_DEVICE_LIST"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_SPARE_DISKS" "$ZFS_SPARE_DEVICE_LIST"
|
||||
fi
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_PREFIX" "$ZFS_PREFIX_VALUE"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "GPU_DEVICE" "$GPU_DEVICE"
|
||||
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "SND_DEVICE" "$SND_DEVICE"
|
||||
fi
|
||||
|
||||
cat >"$OUTPUT_DIR/inspect-facts.env" <<EOF
|
||||
INSPECT_HOSTNAME=$HOSTNAME_VALUE
|
||||
INSPECT_EXISTING_INSTALL=$EXISTING_INSTALL
|
||||
INSPECT_STRONG_SIGNALS=$(IFS=,; echo "${STRONG_SIGNALS[*]-}")
|
||||
|
|
@ -386,9 +481,11 @@ Suggested setup.txt lines
|
|||
Artifacts
|
||||
- system.txt
|
||||
- system.env
|
||||
- inspect-facts.env
|
||||
- suggested-setup.txt
|
||||
- dmesg.txt
|
||||
- pciconf.txt
|
||||
- sndstat.txt
|
||||
- geom-disk-list.txt
|
||||
- ifconfig.txt
|
||||
- pf-status.txt
|
||||
|
|
@ -407,3 +504,9 @@ if [ -n "$APPLY_SETUP_FILE" ]; then
|
|||
echo "Skipped ZFS_DATA_DISKS/ZFS_HOT_SPARES backfill because setup.txt already declares ZFS_LAYOUT=$CURRENT_ZFS_LAYOUT"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$APPLY_SYSTEM_ENV_FILE" ]; then
|
||||
echo "Applied missing system.env values to $APPLY_SYSTEM_ENV_FILE"
|
||||
if [ -n "${CURRENT_SYSTEM_ZFS_LAYOUT:-}" ] && [ "$CURRENT_SYSTEM_ZFS_LAYOUT" != "$SUGGESTED_ZFS_LAYOUT" ]; then
|
||||
echo "Skipped ZFS_DATA_DISKS/ZFS_HOT_SPARES/ZFS_DISKS/ZFS_SPARE_DISKS backfill because system.env already declares ZFS_LAYOUT=$CURRENT_SYSTEM_ZFS_LAYOUT"
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
FIRST_BOOT_SETUP_SCHEMA_VERSION,
|
||||
PLAINTEXT_CREDENTIAL_WARNING,
|
||||
deriveFirstBootIdentity,
|
||||
deriveFirstBootStorageLayout,
|
||||
|
|
@ -17,6 +18,9 @@ TELEGRAM_ADMIN_ID=85126311
|
|||
`);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
setupSchemaVersion: FIRST_BOOT_SETUP_SCHEMA_VERSION,
|
||||
isoRelease: 'v0.10.0',
|
||||
isoGitCommit: null,
|
||||
openRouterApiKey: 'sk-or-v1-test',
|
||||
telegramBotToken: '123:abc',
|
||||
telegramAdminId: 85126311,
|
||||
|
|
@ -41,6 +45,9 @@ TELEGRAM_ADMIN_ID=85126311
|
|||
it('supports comments, quotes, invisible chars, and explicit values', () => {
|
||||
const result = parseFirstBootConfig(`
|
||||
# comment
|
||||
SETUP_SCHEMA_VERSION=1
|
||||
ISO_RELEASE=v9.9.9
|
||||
ISO_GIT_COMMIT=abcdef12
|
||||
OPENROUTER_API_KEY="sk-or-v1-test"
|
||||
TELEGRAM_BOT_TOKEN='123:abc'
|
||||
TELEGRAM_ADMIN_ID=85126311
|
||||
|
|
@ -60,6 +67,9 @@ CLAWDIE_USER_PASSWORD=sudofallback
|
|||
`);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
setupSchemaVersion: 1,
|
||||
isoRelease: 'v9.9.9',
|
||||
isoGitCommit: 'abcdef12',
|
||||
assistantName: 'Mevy',
|
||||
installMode: 'auto',
|
||||
profile: 'quality',
|
||||
|
|
@ -99,6 +109,17 @@ PROFILE=wild
|
|||
).toThrow(/PROFILE/);
|
||||
});
|
||||
|
||||
it('rejects unsupported setup schema versions', () => {
|
||||
expect(() =>
|
||||
parseFirstBootConfig(`
|
||||
SETUP_SCHEMA_VERSION=2
|
||||
OPENROUTER_API_KEY=sk-or-v1-test
|
||||
TELEGRAM_BOT_TOKEN=123:abc
|
||||
TELEGRAM_ADMIN_ID=85126311
|
||||
`),
|
||||
).toThrow(/SETUP_SCHEMA_VERSION=2/);
|
||||
});
|
||||
|
||||
it('rejects invalid zfs layout combinations', () => {
|
||||
expect(() =>
|
||||
parseFirstBootConfig(`
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { normalizeTimeZone } from '../src/locale-profile.js';
|
||||
import { normalizeResourceId } from '../src/platform-layout.js';
|
||||
|
||||
|
|
@ -6,6 +9,9 @@ export type FirstBootInstallMode = 'auto' | 'fresh' | 'upgrade' | 'rescue';
|
|||
export type FirstBootZfsLayout = 'single' | 'mirror' | 'raidz1' | 'raidz2';
|
||||
|
||||
export interface FirstBootConfig {
|
||||
setupSchemaVersion: number;
|
||||
isoRelease: string;
|
||||
isoGitCommit: string | null;
|
||||
openRouterApiKey: string;
|
||||
telegramBotToken: string;
|
||||
telegramAdminId: number;
|
||||
|
|
@ -62,8 +68,12 @@ const DEFAULT_ZFS_LAYOUT: FirstBootZfsLayout = 'single';
|
|||
const DEFAULT_ZFS_DATA_DISKS = 1;
|
||||
const DEFAULT_ZFS_HOT_SPARES = 0;
|
||||
const DEFAULT_ZFS_PREFIX = 'clawdie-runtime';
|
||||
export const FIRST_BOOT_SETUP_SCHEMA_VERSION = 1;
|
||||
const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu;
|
||||
const FIRST_BOOT_KEYS = [
|
||||
'SETUP_SCHEMA_VERSION',
|
||||
'ISO_RELEASE',
|
||||
'ISO_GIT_COMMIT',
|
||||
'OPENROUTER_API_KEY',
|
||||
'TELEGRAM_BOT_TOKEN',
|
||||
'TELEGRAM_ADMIN_ID',
|
||||
|
|
@ -115,6 +125,21 @@ function parseSetupLines(content: string): Partial<Record<FirstBootKey, string>>
|
|||
return values;
|
||||
}
|
||||
|
||||
function currentIsoRelease(): string {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'),
|
||||
) as { version?: string };
|
||||
const version = String(packageJson.version || '').trim();
|
||||
if (version) {
|
||||
return `v${version.replace(/^v/u, '')}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'v0.0.0';
|
||||
}
|
||||
|
||||
function assertPresent(
|
||||
values: Partial<Record<FirstBootKey, string>>,
|
||||
key: FirstBootKey,
|
||||
|
|
@ -126,6 +151,39 @@ function assertPresent(
|
|||
return value;
|
||||
}
|
||||
|
||||
function parseSchemaVersion(value?: string): number {
|
||||
const normalized = (value || '').trim();
|
||||
if (!normalized) return FIRST_BOOT_SETUP_SCHEMA_VERSION;
|
||||
if (!/^\d+$/u.test(normalized)) {
|
||||
throw new Error(
|
||||
`setup.txt has invalid SETUP_SCHEMA_VERSION=${value}. Expected a non-negative integer.`,
|
||||
);
|
||||
}
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
if (parsed !== FIRST_BOOT_SETUP_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`setup.txt SETUP_SCHEMA_VERSION=${parsed} is not supported by this installer. Expected ${FIRST_BOOT_SETUP_SCHEMA_VERSION}.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseIsoRelease(value?: string): string {
|
||||
const normalized = (value || '').trim();
|
||||
return normalized || currentIsoRelease();
|
||||
}
|
||||
|
||||
function parseIsoGitCommit(value?: string): string | null {
|
||||
const normalized = (value || '').trim();
|
||||
if (!normalized) return null;
|
||||
if (!/^[0-9a-f]{7,40}$/iu.test(normalized)) {
|
||||
throw new Error(
|
||||
`setup.txt has invalid ISO_GIT_COMMIT=${value}. Expected a git commit hash.`,
|
||||
);
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function parseProfile(value?: string): FirstBootProfile {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (!normalized) return DEFAULT_PROFILE;
|
||||
|
|
@ -328,6 +386,9 @@ export function parseFirstBootConfig(content: string): FirstBootConfig {
|
|||
'setup.txt ROOT_PASSWORD is not supported. Root login stays locked by default; see install docs.',
|
||||
);
|
||||
}
|
||||
const setupSchemaVersion = parseSchemaVersion(values.SETUP_SCHEMA_VERSION);
|
||||
const isoRelease = parseIsoRelease(values.ISO_RELEASE);
|
||||
const isoGitCommit = parseIsoGitCommit(values.ISO_GIT_COMMIT);
|
||||
const openRouterApiKey = assertPresent(values, 'OPENROUTER_API_KEY');
|
||||
const telegramBotToken = assertPresent(values, 'TELEGRAM_BOT_TOKEN');
|
||||
const telegramAdminId = parseTelegramAdminId(
|
||||
|
|
@ -366,6 +427,9 @@ export function parseFirstBootConfig(content: string): FirstBootConfig {
|
|||
operatorPassword || clawdieUserPassword ? PLAINTEXT_CREDENTIAL_WARNING : null;
|
||||
|
||||
return {
|
||||
setupSchemaVersion,
|
||||
isoRelease,
|
||||
isoGitCommit,
|
||||
openRouterApiKey,
|
||||
telegramBotToken,
|
||||
telegramAdminId,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,27 @@ describe('detectExistingInstall', () => {
|
|||
expect(detection.strongSignals).toContain('service');
|
||||
});
|
||||
|
||||
it('prefers system.env zfs pool/prefix when matching datasets', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'system.env'),
|
||||
['ZFS_POOL=tank', 'ZFS_PREFIX=mevy-runtime', ''].join('\n'),
|
||||
);
|
||||
|
||||
const detection = detectExistingInstall(root, {
|
||||
existsSync: (target) => fs.existsSync(target) && target !== '/usr/local/etc/rc.d/mevy',
|
||||
commandExists: (name) => name === 'zfs',
|
||||
execFileSync: ((cmd: string) => {
|
||||
if (cmd === 'zfs') return 'tank/mevy-runtime\ntank/mevy-runtime/pgdata\n';
|
||||
throw new Error('unexpected command');
|
||||
}) as typeof import('child_process').execFileSync,
|
||||
});
|
||||
|
||||
expect(detection.existing).toBe(true);
|
||||
expect(detection.strongSignals).toContain('zfs');
|
||||
});
|
||||
|
||||
it('returns fresh when no signals are present', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '../src/config.js';
|
||||
import type { FirstBootInstallMode } from './first-boot.js';
|
||||
import { commandExists } from './platform.js';
|
||||
import { loadSystemEnv } from './system-env.js';
|
||||
|
||||
export interface ExistingInstallSignals {
|
||||
envFile: boolean;
|
||||
|
|
@ -84,9 +85,15 @@ function hasRuntimeUser(deps: DetectorDeps): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function hasZfsDataset(deps: DetectorDeps): boolean {
|
||||
function hasZfsDataset(projectRoot: string, deps: DetectorDeps): boolean {
|
||||
if (!deps.commandExists('zfs')) return false;
|
||||
try {
|
||||
const systemEnv = loadSystemEnv(projectRoot, deps);
|
||||
const zfsPrefix = systemEnv.zfsPrefix || ZFS_PREFIX;
|
||||
const exactDataset =
|
||||
systemEnv.zfsPool && zfsPrefix
|
||||
? `${systemEnv.zfsPool}/${zfsPrefix}`
|
||||
: null;
|
||||
const output = deps.execFileSync('zfs', ['list', '-H', '-o', 'name'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
|
|
@ -97,10 +104,15 @@ function hasZfsDataset(deps: DetectorDeps): boolean {
|
|||
.filter(Boolean);
|
||||
return datasets.some(
|
||||
(name) =>
|
||||
name.endsWith(`/${ZFS_PREFIX}`) ||
|
||||
name === ZFS_PREFIX ||
|
||||
name.includes(`/${ZFS_PREFIX}/`) ||
|
||||
name.endsWith(`/${ZFS_PREFIX}/pgdata`),
|
||||
(exactDataset
|
||||
? name === exactDataset ||
|
||||
name.startsWith(`${exactDataset}/`) ||
|
||||
name.endsWith(`${exactDataset}@`)
|
||||
: false) ||
|
||||
name.endsWith(`/${zfsPrefix}`) ||
|
||||
name === zfsPrefix ||
|
||||
name.includes(`/${zfsPrefix}/`) ||
|
||||
name.endsWith(`/${zfsPrefix}/pgdata`),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -116,7 +128,7 @@ export function detectExistingInstall(
|
|||
envFile: hasProjectEnv(projectRoot, resolvedDeps),
|
||||
groupsDir: hasNonEmptyGroupsDir(projectRoot, resolvedDeps),
|
||||
serviceFile: hasInstalledService(resolvedDeps),
|
||||
zfsDataset: hasZfsDataset(resolvedDeps),
|
||||
zfsDataset: hasZfsDataset(projectRoot, resolvedDeps),
|
||||
runtimeUser: hasRuntimeUser(resolvedDeps),
|
||||
};
|
||||
|
||||
|
|
|
|||
10
setup/pf.ts
10
setup/pf.ts
|
|
@ -5,6 +5,7 @@ import { logger } from '../src/logger.js';
|
|||
import { emitStatus } from './status.js';
|
||||
import { getPlatform } from './platform.js';
|
||||
import { SUBNET_BASE } from '../src/config.js';
|
||||
import { resolveSystemEnv } from './system-env.js';
|
||||
|
||||
const PF_CONF = '/etc/pf.conf';
|
||||
const PF_WARDEN_INCLUDE = '/etc/pf.warden.conf';
|
||||
|
|
@ -19,6 +20,9 @@ function pfEnabled(): boolean {
|
|||
}
|
||||
|
||||
function detectExtIf(): string {
|
||||
const systemEnv = resolveSystemEnv(process.cwd());
|
||||
if (systemEnv.networkExternalIf) return systemEnv.networkExternalIf;
|
||||
|
||||
const env = (process.env.EXT_IF || '').trim();
|
||||
if (env) return env;
|
||||
|
||||
|
|
@ -63,7 +67,11 @@ export async function run(): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
const wardenIf = (process.env.WARDEN_BRIDGE || 'warden0').trim() || 'warden0';
|
||||
const systemEnv = resolveSystemEnv(process.cwd());
|
||||
const wardenIf =
|
||||
systemEnv.networkInternalIf ||
|
||||
(process.env.WARDEN_BRIDGE || 'warden0').trim() ||
|
||||
'warden0';
|
||||
const wardenNet = (process.env.WARDEN_SUBNET || `${SUBNET_BASE}.0/24`).trim();
|
||||
const extIf = detectExtIf();
|
||||
|
||||
|
|
|
|||
137
setup/system-env.test.ts
Normal file
137
setup/system-env.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
SYSTEM_ENV_SCHEMA_VERSION,
|
||||
loadSystemEnv,
|
||||
resolveSystemEnv,
|
||||
} from './system-env.js';
|
||||
|
||||
function makeProjectRoot(): string {
|
||||
const base = path.join(process.cwd(), 'tmp', 'tests');
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
return fs.mkdtempSync(path.join(base, 'clawdie-system-env-'));
|
||||
}
|
||||
|
||||
function removeDir(dirPath: string): void {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe('system env', () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) {
|
||||
removeDir(roots.pop()!);
|
||||
}
|
||||
});
|
||||
|
||||
it('loads blank defaults when system.env is absent', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
|
||||
expect(loadSystemEnv(root)).toEqual({
|
||||
systemSchemaVersion: SYSTEM_ENV_SCHEMA_VERSION,
|
||||
networkExternalIf: null,
|
||||
networkInternalIf: null,
|
||||
tailscaleIf: null,
|
||||
zfsPool: null,
|
||||
zfsLayout: null,
|
||||
zfsDataDisks: null,
|
||||
zfsHotSpares: null,
|
||||
zfsDisks: [],
|
||||
zfsSpareDisks: [],
|
||||
zfsPrefix: null,
|
||||
gpuDevice: null,
|
||||
sndDevice: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses explicit system.env values', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'system.env'),
|
||||
[
|
||||
'SYSTEM_SCHEMA_VERSION=1',
|
||||
'NETWORK_EXTERNAL_IF=em0',
|
||||
'NETWORK_INTERNAL_IF=warden0',
|
||||
'TAILSCALE_IF=tailscale0',
|
||||
'ZFS_POOL=tank',
|
||||
'ZFS_LAYOUT=raidz1',
|
||||
'ZFS_DATA_DISKS=3',
|
||||
'ZFS_HOT_SPARES=1',
|
||||
'ZFS_DISKS=ada0,ada1,ada2',
|
||||
'ZFS_SPARE_DISKS=ada3',
|
||||
'ZFS_PREFIX=mevy data',
|
||||
'GPU_DEVICE=drm0',
|
||||
'SND_DEVICE=pcm0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(loadSystemEnv(root)).toEqual({
|
||||
systemSchemaVersion: 1,
|
||||
networkExternalIf: 'em0',
|
||||
networkInternalIf: 'warden0',
|
||||
tailscaleIf: 'tailscale0',
|
||||
zfsPool: 'tank',
|
||||
zfsLayout: 'raidz1',
|
||||
zfsDataDisks: 3,
|
||||
zfsHotSpares: 1,
|
||||
zfsDisks: ['ada0', 'ada1', 'ada2'],
|
||||
zfsSpareDisks: ['ada3'],
|
||||
zfsPrefix: 'mevy-data',
|
||||
gpuDevice: 'drm0',
|
||||
sndDevice: 'pcm0',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported system.env schema versions', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'system.env'),
|
||||
['SYSTEM_SCHEMA_VERSION=2', ''].join('\n'),
|
||||
);
|
||||
|
||||
expect(() => loadSystemEnv(root)).toThrow(/SYSTEM_SCHEMA_VERSION=2/);
|
||||
});
|
||||
|
||||
it('resolves autodetected values only for blanks', () => {
|
||||
const root = makeProjectRoot();
|
||||
roots.push(root);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'system.env'),
|
||||
['NETWORK_EXTERNAL_IF=', 'NETWORK_INTERNAL_IF=bridge2', 'GPU_DEVICE=', ''].join('\n'),
|
||||
);
|
||||
|
||||
const resolved = resolveSystemEnv(root, {
|
||||
commandExists: (name) => name === 'route' || name === 'ifconfig',
|
||||
existsSync: (filePath) =>
|
||||
filePath === '/dev/drm0' ||
|
||||
filePath === '/dev/pcm0' ||
|
||||
fs.existsSync(filePath),
|
||||
execFileSync: ((cmd: string, args: string[]) => {
|
||||
if (cmd === 'route') return 'interface: vtnet0\n';
|
||||
if (cmd === 'ifconfig') return 'lo0 warden0 tailscale0';
|
||||
throw new Error(`unexpected command ${cmd} ${args.join(' ')}`);
|
||||
}) as typeof import('child_process').execFileSync,
|
||||
});
|
||||
|
||||
expect(resolved.networkExternalIf).toBe('vtnet0');
|
||||
expect(resolved.networkInternalIf).toBe('bridge2');
|
||||
expect(resolved.tailscaleIf).toBe('tailscale0');
|
||||
expect(resolved.gpuDevice).toBe('drm0');
|
||||
expect(resolved.sndDevice).toBe('pcm0');
|
||||
expect(resolved.detected).toEqual({
|
||||
networkExternalIf: true,
|
||||
networkInternalIf: false,
|
||||
tailscaleIf: true,
|
||||
gpuDevice: true,
|
||||
sndDevice: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
245
setup/system-env.ts
Normal file
245
setup/system-env.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type { FirstBootZfsLayout } from './first-boot.js';
|
||||
import { commandExists } from './platform.js';
|
||||
import { normalizeResourceId } from '../src/platform-layout.js';
|
||||
|
||||
export interface SystemEnvConfig {
|
||||
systemSchemaVersion: number;
|
||||
networkExternalIf: string | null;
|
||||
networkInternalIf: string | null;
|
||||
tailscaleIf: string | null;
|
||||
zfsPool: string | null;
|
||||
zfsLayout: FirstBootZfsLayout | null;
|
||||
zfsDataDisks: number | null;
|
||||
zfsHotSpares: number | null;
|
||||
zfsDisks: string[];
|
||||
zfsSpareDisks: string[];
|
||||
zfsPrefix: string | null;
|
||||
gpuDevice: string | null;
|
||||
sndDevice: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedSystemEnv extends SystemEnvConfig {
|
||||
detected: {
|
||||
networkExternalIf: boolean;
|
||||
networkInternalIf: boolean;
|
||||
tailscaleIf: boolean;
|
||||
gpuDevice: boolean;
|
||||
sndDevice: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SystemEnvDeps {
|
||||
commandExists: (name: string) => boolean;
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: (filePath: string) => boolean;
|
||||
readFileSync: typeof fs.readFileSync;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: SystemEnvDeps = {
|
||||
commandExists,
|
||||
execFileSync,
|
||||
existsSync: fs.existsSync,
|
||||
readFileSync: fs.readFileSync,
|
||||
};
|
||||
|
||||
export const SYSTEM_ENV_SCHEMA_VERSION = 1;
|
||||
|
||||
function parseScalar(content: string, key: string): string | null {
|
||||
const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
||||
if (!match) return null;
|
||||
const raw = match[1].trim().replace(/^['"]|['"]$/gu, '');
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function parseInteger(content: string, key: string): number | null {
|
||||
const value = parseScalar(content, key);
|
||||
if (!value) return null;
|
||||
if (!/^\d+$/u.test(value)) return null;
|
||||
return Number.parseInt(value, 10);
|
||||
}
|
||||
|
||||
function parseCsv(content: string, key: string): string[] {
|
||||
const value = parseScalar(content, key);
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseLayout(content: string): FirstBootZfsLayout | null {
|
||||
const value = (parseScalar(content, 'ZFS_LAYOUT') || '').toLowerCase();
|
||||
if (!value) return null;
|
||||
if (
|
||||
value === 'single' ||
|
||||
value === 'mirror' ||
|
||||
value === 'raidz1' ||
|
||||
value === 'raidz2'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSystemEnv(
|
||||
projectRoot: string = process.cwd(),
|
||||
deps: Partial<SystemEnvDeps> = {},
|
||||
): SystemEnvConfig {
|
||||
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
||||
const systemEnvFile = path.join(projectRoot, 'system.env');
|
||||
if (!resolvedDeps.existsSync(systemEnvFile)) {
|
||||
return {
|
||||
systemSchemaVersion: SYSTEM_ENV_SCHEMA_VERSION,
|
||||
networkExternalIf: null,
|
||||
networkInternalIf: null,
|
||||
tailscaleIf: null,
|
||||
zfsPool: null,
|
||||
zfsLayout: null,
|
||||
zfsDataDisks: null,
|
||||
zfsHotSpares: null,
|
||||
zfsDisks: [],
|
||||
zfsSpareDisks: [],
|
||||
zfsPrefix: null,
|
||||
gpuDevice: null,
|
||||
sndDevice: null,
|
||||
};
|
||||
}
|
||||
|
||||
const content = resolvedDeps.readFileSync(systemEnvFile, 'utf-8');
|
||||
const parsedSchema = parseInteger(content, 'SYSTEM_SCHEMA_VERSION');
|
||||
if (parsedSchema !== null && parsedSchema !== SYSTEM_ENV_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`system.env SYSTEM_SCHEMA_VERSION=${parsedSchema} is not supported. Expected ${SYSTEM_ENV_SCHEMA_VERSION}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
systemSchemaVersion: parsedSchema ?? SYSTEM_ENV_SCHEMA_VERSION,
|
||||
networkExternalIf: parseScalar(content, 'NETWORK_EXTERNAL_IF'),
|
||||
networkInternalIf: parseScalar(content, 'NETWORK_INTERNAL_IF'),
|
||||
tailscaleIf: parseScalar(content, 'TAILSCALE_IF'),
|
||||
zfsPool: parseScalar(content, 'ZFS_POOL'),
|
||||
zfsLayout: parseLayout(content),
|
||||
zfsDataDisks: parseInteger(content, 'ZFS_DATA_DISKS'),
|
||||
zfsHotSpares: parseInteger(content, 'ZFS_HOT_SPARES'),
|
||||
zfsDisks: parseCsv(content, 'ZFS_DISKS'),
|
||||
zfsSpareDisks: parseCsv(content, 'ZFS_SPARE_DISKS'),
|
||||
zfsPrefix: (() => {
|
||||
const raw = parseScalar(content, 'ZFS_PREFIX');
|
||||
return raw ? normalizeResourceId(raw) || null : null;
|
||||
})(),
|
||||
gpuDevice: parseScalar(content, 'GPU_DEVICE'),
|
||||
sndDevice: parseScalar(content, 'SND_DEVICE'),
|
||||
};
|
||||
}
|
||||
|
||||
function detectDefaultRouteInterface(deps: SystemEnvDeps): string | null {
|
||||
if (!deps.commandExists('route')) return null;
|
||||
try {
|
||||
const out = deps.execFileSync('route', ['-n', 'get', 'default'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return out.match(/^\s*interface:\s*(\S+)/mu)?.[1]?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectInterfaces(deps: SystemEnvDeps): string[] {
|
||||
if (!deps.commandExists('ifconfig')) return [];
|
||||
try {
|
||||
const out = deps.execFileSync('ifconfig', ['-l'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return out
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function detectInternalIf(deps: SystemEnvDeps): string | null {
|
||||
const interfaces = detectInterfaces(deps);
|
||||
if (interfaces.includes('warden0')) return 'warden0';
|
||||
const bridge = interfaces.find((name) => /^bridge\d+$/u.test(name));
|
||||
return bridge || null;
|
||||
}
|
||||
|
||||
function detectTailscaleIf(deps: SystemEnvDeps): string | null {
|
||||
const interfaces = detectInterfaces(deps);
|
||||
return interfaces.includes('tailscale0') ? 'tailscale0' : null;
|
||||
}
|
||||
|
||||
function detectGpuDevice(deps: SystemEnvDeps): string | null {
|
||||
if (deps.existsSync('/dev/drm0')) return 'drm0';
|
||||
if (deps.existsSync('/dev/dri/card0')) return 'drm0';
|
||||
if (!deps.commandExists('pciconf')) return null;
|
||||
try {
|
||||
const out = deps.execFileSync('pciconf', ['-lv'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (/(VGA|display)/iu.test(out)) {
|
||||
return 'auto-gpu';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectSndDevice(deps: SystemEnvDeps): string | null {
|
||||
if (deps.existsSync('/dev/pcm0')) return 'pcm0';
|
||||
if (deps.commandExists('cat') && deps.existsSync('/dev/sndstat')) {
|
||||
try {
|
||||
const out = deps.execFileSync('cat', ['/dev/sndstat'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const match = out.match(/\b(pcm\d+)\b/u);
|
||||
return match?.[1] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveSystemEnv(
|
||||
projectRoot: string = process.cwd(),
|
||||
deps: Partial<SystemEnvDeps> = {},
|
||||
): ResolvedSystemEnv {
|
||||
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
||||
const loaded = loadSystemEnv(projectRoot, resolvedDeps);
|
||||
|
||||
const detectedExternal = !loaded.networkExternalIf;
|
||||
const detectedInternal = !loaded.networkInternalIf;
|
||||
const detectedTailscale = !loaded.tailscaleIf;
|
||||
const detectedGpu = !loaded.gpuDevice;
|
||||
const detectedSnd = !loaded.sndDevice;
|
||||
|
||||
return {
|
||||
...loaded,
|
||||
networkExternalIf:
|
||||
loaded.networkExternalIf || detectDefaultRouteInterface(resolvedDeps),
|
||||
networkInternalIf: loaded.networkInternalIf || detectInternalIf(resolvedDeps),
|
||||
tailscaleIf: loaded.tailscaleIf || detectTailscaleIf(resolvedDeps),
|
||||
gpuDevice: loaded.gpuDevice || detectGpuDevice(resolvedDeps),
|
||||
sndDevice: loaded.sndDevice || detectSndDevice(resolvedDeps),
|
||||
detected: {
|
||||
networkExternalIf: detectedExternal,
|
||||
networkInternalIf: detectedInternal,
|
||||
tailscaleIf: detectedTailscale,
|
||||
gpuDevice: detectedGpu,
|
||||
sndDevice: detectedSnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
55
setup/zfs-metadata.test.ts
Normal file
55
setup/zfs-metadata.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getZfsMetadata, setZfsMetadata } from './zfs-metadata.js';
|
||||
|
||||
describe('zfs metadata helpers', () => {
|
||||
it('writes namespaced ZFS properties for non-empty values', () => {
|
||||
const exec = vi.fn();
|
||||
|
||||
setZfsMetadata(
|
||||
'zroot/clawdie-runtime',
|
||||
{
|
||||
'install-uuid': 'abc123',
|
||||
'iso-release': 'v0.10.0',
|
||||
empty: '',
|
||||
skipped: null,
|
||||
},
|
||||
{ execFileSync: exec as never },
|
||||
);
|
||||
|
||||
expect(exec).toHaveBeenCalledTimes(2);
|
||||
expect(exec).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'zfs',
|
||||
['set', 'org.clawdie:install-uuid=abc123', 'zroot/clawdie-runtime'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(exec).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'zfs',
|
||||
['set', 'org.clawdie:iso-release=v0.10.0', 'zroot/clawdie-runtime'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads namespaced ZFS properties and normalizes missing values', () => {
|
||||
const exec = vi.fn((cmd: string, args: string[]) => {
|
||||
const prop = args[4];
|
||||
if (prop === 'org.clawdie:install-uuid') return 'abc123\n';
|
||||
if (prop === 'org.clawdie:iso-release') return '-\n';
|
||||
throw new Error('missing');
|
||||
});
|
||||
|
||||
expect(
|
||||
getZfsMetadata(
|
||||
'zroot/clawdie-runtime',
|
||||
['install-uuid', 'iso-release', 'assistant-name'],
|
||||
{ execFileSync: exec as never },
|
||||
),
|
||||
).toEqual({
|
||||
'install-uuid': 'abc123',
|
||||
'iso-release': null,
|
||||
'assistant-name': null,
|
||||
});
|
||||
});
|
||||
});
|
||||
65
setup/zfs-metadata.ts
Normal file
65
setup/zfs-metadata.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
|
||||
export const ZFS_METADATA_PREFIX = 'org.clawdie';
|
||||
|
||||
interface ZfsMetadataDeps {
|
||||
execFileSync: typeof execFileSync;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: ZfsMetadataDeps = {
|
||||
execFileSync,
|
||||
};
|
||||
|
||||
function propertyName(key: string): string {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/u.test(normalized)) {
|
||||
throw new Error(`invalid ZFS metadata key: ${key}`);
|
||||
}
|
||||
return `${ZFS_METADATA_PREFIX}:${normalized}`;
|
||||
}
|
||||
|
||||
function normalizeValue(value: string | number | boolean): string {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function setZfsMetadata(
|
||||
dataset: string,
|
||||
props: Record<string, string | number | boolean | null | undefined>,
|
||||
deps: Partial<ZfsMetadataDeps> = {},
|
||||
): void {
|
||||
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
||||
for (const [key, raw] of Object.entries(props)) {
|
||||
if (raw === null || raw === undefined) continue;
|
||||
const value = normalizeValue(raw);
|
||||
if (!value) continue;
|
||||
resolvedDeps.execFileSync(
|
||||
'zfs',
|
||||
['set', `${propertyName(key)}=${value}`, dataset],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getZfsMetadata(
|
||||
dataset: string,
|
||||
keys: string[],
|
||||
deps: Partial<ZfsMetadataDeps> = {},
|
||||
): Record<string, string | null> {
|
||||
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
||||
const result: Record<string, string | null> = {};
|
||||
for (const key of keys) {
|
||||
const prop = propertyName(key);
|
||||
try {
|
||||
const out = resolvedDeps.execFileSync(
|
||||
'zfs',
|
||||
['get', '-H', '-o', 'value', prop, dataset],
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
const value = out.trim();
|
||||
result[key] = !value || value === '-' ? null : value;
|
||||
} catch {
|
||||
result[key] = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
24
system.env.example
Normal file
24
system.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# system.env
|
||||
# Hardware and host-layout hints for Clawdie.
|
||||
# Leave values blank to autodetect. Fill them only if you already know
|
||||
# your FreeBSD device names and want to override detection.
|
||||
|
||||
SYSTEM_SCHEMA_VERSION=1
|
||||
|
||||
# Network
|
||||
NETWORK_EXTERNAL_IF=
|
||||
NETWORK_INTERNAL_IF=
|
||||
TAILSCALE_IF=
|
||||
|
||||
# Storage
|
||||
ZFS_POOL=
|
||||
ZFS_LAYOUT=
|
||||
ZFS_DATA_DISKS=
|
||||
ZFS_HOT_SPARES=
|
||||
ZFS_DISKS=
|
||||
ZFS_SPARE_DISKS=
|
||||
ZFS_PREFIX=
|
||||
|
||||
# Hardware
|
||||
GPU_DEVICE=
|
||||
SND_DEVICE=
|
||||
Loading…
Add table
Reference in a new issue