Skip to content
aloli-crystalPublic

About

Crystal configuration management for FreeBSD 15 — agentless SSH, bootstrap via mfsBSD, idempotent YAML recipes

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

beryl

Crystal tool for FreeBSD provisioning, agentless over SSH, multi-account and multi-provider. Switches a bare-metal server on OVH, Scaleway or Dedibox into its Linux rescue mode, installs FreeBSD 15 via mfsBSD-in-QEMU, then applies declarative configuration from a YAML tree.

Current version: 0.1.41 (24 April 2026).
Repository: https://github.com/aloli-crystal/beryl
License: MIT (see section at the bottom of this page).

Note

beryl sits before deploy: it prepares the FreeBSD server (OS, users, system packages, SSH keys, sshd hardening), then deploy deploys Marten or Kemal applications on top.

Overview

OVH, Scaleway and Online.net/Dedibox do not offer FreeBSD as a native image. The manual procedure — KVM-over-IP, custom ISO, hand-rolled rescue mode — is neither reproducible nor automatable. beryl automates the full cycle from the operator’s workstation:

  1. beryl init <account> creates the ~/.config/beryl/<account>/ tree (ADR-014).

  2. beryl add-provider <account>/<provider> stores API credentials in ~/.config/beryl/.env.yml (kept outside the repo).

  3. beryl add-domain <account>/<domain> declares the DNS zone, the default hosting provider, and user SSH keys.

  4. beryl rescue <host> triggers the provider API to reboot into Linux rescue, then waits for SSH to come back.

  5. beryl scan <host> --dns --apply --hostname=<short> connects to the rescue, detects disks (lsblk -b -d), lays down A/AAAA and PTR DNS records, and writes a ready-to-bootstrap <host>.yml.

  6. beryl wipe <host> clears leftover ZFS pools and partitions.

  7. beryl bootstrap <host> launches an mfsBSD VM inside the rescue (via QEMU) and installs FreeBSD 15 on the declared disks (ADR-012/013: post-install runs outside the chroot).

  8. beryl follow-install <host> tails the bsdinstall.log from the mfsBSD VM in real time.

  9. beryl apply <host> reconciles the effective config (packages, users, SSH keys, sudoers) with the running FreeBSD server — work in progress, currently limited to a bootstrap-only cycle.

Multi-account architecture (ADR-014)

Configuration lives in ~/.config/beryl/, organised by account, then by domain:

~/.config/beryl/
├── _default.yml                 # FreeBSD baseline (timezone, admin/deploy users, pkgs)
├── .env.yml                     # credentials per account × provider (0600)
├── initech/                       # one account
│   ├── _account.yml             # metadata (legal name, contact)
│   ├── initech.example.yml            # one domain (DNS zone, SSH keys, default provider)
│   ├── initech.example/
│   │   ├── loulou.initech.example.yml # one host
│   │   └── mail.initech.example.yml
│   └── initech-fr.example.yml
└── other-account/
    └── example.com.yml

The merge is hierarchical (most specific wins): _default.yml → <account>/<domain>.yml → <account>/<domain>/<host>.yml.

Every host-taking subcommand accepts a path-like syntax that mirrors the tree: initech/loulou.initech.example, or initech/loulou if the domain is unambiguous, or simply loulou if only one account is declared.

Key concepts

  • Agentless: nothing to install on the target. beryl drives everything over SSH (the ssh shard fully isolates beryl from ~/.ssh/config).

  • One API shard per provider — aloli-crystal/api-ovh, aloli-crystal/api-scaleway, aloli-crystal/api-dedibox. Each shard is standalone, MIT-licensed, unit-tested.

  • Heterogeneous IDs across providers: a textual service_name for OVH, a UUID v4 for Scaleway, an integer for Dedibox. The initech/<ID> form works on all three: beryl resolves the public IP via the provider API and carries on.

  • Single Crystal binary: shards build --release yields a static bin/beryl that runs on any POSIX workstation without a runtime.

Supported providers

Three providers are supported as of April 2026, in alphabetical order.

Dedibox (Online.net)

Dedibox identifies servers by an integer (e.g. 186260). Credentials are registered through beryl add-provider initech/dedibox (API key + secret key obtained from https://console.online.net/).

The Dedibox rescue is a Debian 12 image prepared through the API. Critical quirk: the rescue user is not root but sd-<id> (e.g. sd-186260). The password is generated by the API on each prepare_rescue call, and the IAM SSH key attached to the account is auto-injected by the platform into /home/sd-<id>/.ssh/authorized_keys. beryl then orchestrates a promotion to root so the rest of the flow (scan, bootstrap, apply) works the same way as on OVH.

The beryl rescue <host> flow logs four numbered steps:

  1. 1/4 — prepare_rescue(server_id, image=debian-12_amd64): returns the sd-<id> login and a temporary password.

  2. 2/4 — reboot(server_id, reason="beryl rescue"). The Dedibox API occasionally returns false on this POST even when the hardware does reboot; beryl logs a warning and proceeds on the actual TCP signal.

  3. 2b/4 — waits for sshd to drop (proof that the hardware has gone down).

  4. 3/4 — waits for sd-<id>@host to answer (rescue Debian ready).

  5. 4/4 — promote: copies authorized_keys into /root/.ssh/ and enables PermitRootLogin yes via sudo -S (password fed on stdin, never placed on the command line).

Known limitation: the Dedibox/Online API does not expose reverse DNS (PTR). beryl scan --dns happily lays down A and AAAA records through the DNS provider declared on the domain (OVH in practice) and renames the server in the console when possible, but the PTR still has to be set manually on https://console.online.net/ (limitation documented in aloli-crystal/api-dedibox).

Concrete example, one command per block so each can be copy-pasted on its own:

  1. Switch into Debian rescue through the Dedibox API (4 numbered steps).

    beryl rescue initech/186260 --provider=dedibox --apply
  2. Disk scan, A/AAAA records, write cookie.initech.example.yml.

    beryl scan initech/186260 --provider=dedibox --dns --apply --hostname=cookie
  3. Wipe leftover ZFS pools / partitions on the declared disks.

    beryl wipe initech/cookie.initech.example --apply
  4. Install FreeBSD 15 via mfsBSD-in-QEMU.

    beryl bootstrap initech/cookie.initech.example --apply

OVH

OVH identifies a dedicated server by its textual service_name, typically of the form ns3156789.ip-51-83-6.eu. Credentials are obtained through beryl add-provider initech/ovh: beryl guides the token creation at https://eu.api.ovh.com/createToken/ then calls POST /auth/credential to produce a consumer key with the exact set of required access rules.

The OVH rescue is rescue64-pro, a 64-bit Debian image. The API switch is a two-stage dance:

  1. dedicated_servers.prepare_rescue(service_name, ssh_key_name) sets the boot order to rescue and returns an OVH task.

  2. beryl polls the task until it reaches a terminal state (init → todo → doing → done). A live log_step counter shows time spent in each state, so the operator immediately sees if a transition hangs.

Once the task is done, beryl waits for SSH to answer on root@host. The SSH key injected into the rescue must be declared in the OVH panel; its label (ssh_key_name) is then referenced by ovh.ssh_key_name in the domain YAML. If the OVH account contains only one key, beryl auto-selects it and logs the fact.

Concrete example, one command per block so each can be copy-pasted on its own:

  1. Switch into rescue64-pro through the OVH API, poll the task.

    beryl rescue initech/ns3156789.ip-51-83-6.eu --domain=initech.example --apply
  2. Disk scan, A/AAAA + PTR records (IPv4/IPv6), rename the OVH panel.

    beryl scan initech/ns3156789.ip-51-83-6.eu --domain=initech.example --dns --apply --hostname=cookie
  3. Wipe leftover ZFS pools / partitions.

    beryl wipe initech/cookie.initech.example --apply
  4. Install FreeBSD 15.

    beryl bootstrap initech/cookie.initech.example --apply
  5. In a second terminal, tail bsdinstall.log from the mfsBSD VM.

    beryl follow-install initech/cookie.initech.example

With --dns, scan lays down A/AAAA records in the DNS zone, PTR records through the OVH API (IPv4 + IPv6), and renames the server in the panel (the service_name stays, but the display name becomes the desired short name).

Scaleway

Scaleway Elastic Metal identifies servers by UUID v4 (e.g. 9783705f-f86f-4bf7-88ad-4c92b920e9ba), scoped by geographic zone (fr-par-1, fr-par-2, pl-waw-3…). Credentials are registered through beryl add-provider initech/scaleway — secret key + project ID, with the required IAM permissions to tick manually in the console (the Scaleway API has no auto-gen path).

The Scaleway rescue is a live Ubuntu image. Three critical specifics, source: https://www.scaleway.com/en/docs/bare-metal/elastic-metal/how-to/use-rescue-mode/

  1. The rescue user is rescue, not root. It is a passwordless sudoer in the official rescue image.

  2. SSH keys injected into the rescue are ONLY those from install.ssh_key_ids, set at the server’s initial install. Adding a key to the Scaleway project after the server is created does not propagate to the rescue: the key will be missing from /home/rescue/.ssh/authorized_keys.

  3. To resync keys you must redo an install (destructive operation). beryl exposes this through beryl scaleway-reinstall <host>, which calls POST /servers/{id}/install with the up-to-date project keys. Only safe on a brand-new server or one whose OS is expendable.

To keep the flow uniform with OVH and Dedibox, beryl rescue automatically promotes rescue → root once SSH is back: copies /home/rescue/.ssh/authorized_keys into /root/.ssh/, sets PermitRootLogin yes in /etc/ssh/sshd_config.d/beryl.conf, reloads sshd. The subsequent commands (scan, bootstrap, apply) then connect as root@host like anywhere else.

Before the Rescue reboot, beryl runs a pre-check (step 1/3 in the logs): it reads install.ssh_key_ids from the server, fetches each public key by ID, and compares the base64 payload with the local host.identity_file.pub. If the local key is missing from that list, beryl refuses to proceed and explicitly points to beryl scaleway-reinstall.

Concrete example (chouquette, zone pl-waw-3), one command per block so each can be copy-pasted on its own:

  1. Pre-check install.ssh_key_ids, then switch into Ubuntu rescue. beryl resolves the IP and the zone from the UUID via the API, and promotes rescue → root once SSH is back.

    beryl rescue initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba --provider=scaleway --apply
  2. Run only if step 1 bailed out on the pre-check: resync SSH keys through the official API. DESTRUCTIVE: reinstalls the OS on disk. Safe only on a brand-new server.

    beryl scaleway-reinstall initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba
  3. Disk scan, A/AAAA, IPv4 reverse DNS, rename the Scaleway console.

    beryl scan initech/9783705f-f86f-4bf7-88ad-4c92b920e9ba --provider=scaleway --dns --apply --hostname=chouquette
  4. Wipe leftover ZFS pools / partitions.

    beryl wipe initech/chouquette.initech.example --apply
  5. Install FreeBSD 15.

    beryl bootstrap initech/chouquette.initech.example --apply

Installation

  1. Clone the repository.

    git clone https://github.com/aloli-crystal/beryl.git
  2. Fetch dependencies (Initech shards + stdlib).

    cd beryl && shards install
  3. Build the release binary.

    shards build --release

The beryl binary lands in ./bin/beryl. No runtime dependency beyond libssl / libcrypto, present by default on macOS and Linux.

Quick start

Every command is a dry run by default (it shows what it would do); --apply actually runs it.

  1. Create the ~/.config/beryl/initech/ tree (interactive prompts).

    beryl init initech --apply
  2. Store OVH credentials in ~/.config/beryl/.env.yml.

    beryl add-provider initech/ovh --apply
  3. Declare DNS zone, default provider, SSH keys.

    beryl add-domain initech/initech.example --apply
  4. Switch the server into rescue.

    beryl rescue initech/cookie.initech.example --apply
  5. Detect disks, set up DNS, write the host YAML.

    beryl scan initech/cookie.initech.example --dns --apply --hostname=cookie
  6. Wipe leftover pools / partitions.

    beryl wipe initech/cookie.initech.example --apply
  7. Install FreeBSD 15.

    beryl bootstrap initech/cookie.initech.example --apply

The full CLI reference (subcommands, shorts, flags) lives in docs/CLI-REFERENCE.adoc. Detailed architecture and roadmap are in ARCHITECTURE.adoc and docs/adr/.

Upgrading PostgreSQL to a new major version

beryl pg-upgrade <host> upgrades the PostgreSQL server of a FreeBSD host to a new major version (e.g. 18 → 19) with pg_upgrade, following the procedure from the FreeBSD ports UPDATING file. Dry-run by default: beryl detects the versions, the data dirs and the cluster options, checks the preconditions and prints the plan.

beryl pg-upgrade acme/db1.example.net --to 19 --stop-services myapp
beryl pg-upgrade acme/db1.example.net --to 19 --stop-services myapp --apply

What beryl checks before touching anything:

  • the target postgresqlNN-server package is in the repository (--to, otherwise the most recent version in the repository);

  • installed packages that depend on PostgreSQL (drivers, extensions) exist in the repository rebuilt for the new version. Otherwise beryl refuses: rebuild the repository first with DEFAULT_VERSIONS+= pgsql=NN;

  • disk space (data dir copy + pg_dumpall backup).

The new cluster gets the encoding, locale and data checksums state of the old one. pg_upgrade --check runs first. If anything fails before the new server starts, beryl automatically rolls back to the old version. --link is faster but rules out any rollback once the new server has started.

The old data dir is kept. Once the new version is validated:

beryl pg-upgrade acme/db1.example.net --cleanup --apply

RAID levels (ZFS)

beryl scan asks for each pool’s RAID level as a number. Numbers 5, 6 and 7 are a beryl convention, by analogy with hardware RAID; ZFS itself says raidz, raidz2 and raidz3. Only the levels achievable with the selected disks are offered.

Choice ZFS mode In plain words Min. disks Failures tolerated Usable capacity (N disks)

0

stripe

Data spread over all disks, no copy. Fastest and most space, but one lost disk = everything lost.

1

0

N

1

mirror

Mirror: every disk holds the same copy.

2

N − 1

1 disk

5

raidz

Single parity (RAID 5 equivalent).

3

1

N − 1

6

raidz2

Double parity (RAID 6 equivalent).

4

2

N − 2

7

raidz3

Triple parity (ZFS-specific).

5

3

N − 3

10

mirror_stripe

Mirrored pairs, data spread over the pairs (RAID 10).

4, even number

1 per pair

N / 2

Rules of thumb:

  • 2 disks: 1 (mirror) is the only redundant choice.

  • 3 disks: 5 (raidz).

  • 4 disks or more: 6 (raidz2) for safety and space — it survives two simultaneous failures, valuable with large disks whose rebuild takes hours; 10 for performance (databases), at the cost of half the space.

  • 0 only suits disposable data (cache) or when relying entirely on backups. For a multi-disk zroot in 0, beryl scan warns and asks for confirmation.

Package builds (poudriere)

On a builder managed by the poudriere-build primitive, beryl build-status shows the build state, read-only: running (since when, phase, packages being built), succeeded, failed or never built; counters; FAILED packages first with the path of their poudriere log; built packages, longest first; last successful build and overlay commits. Output is in French.

beryl build-status pkg.example.net
beryl build-status pkg.example.net --major 15 --follow
beryl build-status pkg.example.net --all --sort name
beryl build-status pkg.example.net --json

--follow refreshes every 30 s until the build ends, then exits 0 (success) or 1 (failure). Exit codes: 0 success / running, 1 last build failed, 2 SSH / usage.

Licence

MIT © ALOLI sas. See LICENSE.

About

Crystal configuration management for FreeBSD 15 — agentless SSH, bootstrap via mfsBSD, idempotent YAML recipes

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages