Packer build definition, cloud-init autoinstall, GHCup toolchain script, boot-time registration agent (state-hub + autossh dual tunnel), systemd unit, key injection, remote-build Makefile, smoke test, and deployment README. All 15 tasks complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
790 lines
25 KiB
Markdown
790 lines
25 KiB
Markdown
---
|
|
id: CUST-WP-0032
|
|
type: workplan
|
|
title: "Haskell Build Machine — VirtualBox Image & State-Hub Integration"
|
|
domain: railiance
|
|
repo: the-custodian
|
|
status: done
|
|
owner: custodian
|
|
topic_slug: railiance
|
|
created: "2026-04-20"
|
|
updated: "2026-04-20"
|
|
decisions_resolved: "2026-04-20"
|
|
state_hub_workstream_id: "f2bfac74-de8a-4c86-9aa9-4d95a92336e2"
|
|
---
|
|
|
|
# Haskell Build Machine — VirtualBox Image & State-Hub Integration
|
|
|
|
## Goal
|
|
|
|
Build a reproducible VirtualBox image that boots as a Haskell build sandbox
|
|
on any capable laptop, self-registers with the Custodian State Hub via the
|
|
capability catalog, and integrates with the ops-bridge SSH tunnel system for
|
|
seamless remote builds.
|
|
|
|
The motivating use case: offload computationally expensive Haskell compilation
|
|
from the development workstation (WSL2/CoulombCore) to a dedicated, beefy
|
|
laptop running VirtualBox — without changing the developer workflow.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Laptop (VirtualBox host)
|
|
└── haskell-build VM (Ubuntu 24.04, bridged network)
|
|
├── GHC 9.8.4 (default) + GHC 9.6.6 + Cabal via GHCup (no Stack, no HLS)
|
|
├── build-agent (systemd): registers with state-hub on boot
|
|
│ └── POST /capability-catalog/ { capability_type: "haskell-build-agent" }
|
|
└── autossh: two tunnels in one SSH connection
|
|
├── -R 12222:localhost:22 (reverse: workstation → VM SSH)
|
|
└── -L 18000:localhost:8000 (forward: VM → state-hub, port 18000)
|
|
|
|
Workstation (WSL2)
|
|
├── state-hub: sees haskell-build-* capability entries, knows tunnel port
|
|
├── ops-bridge: optional — manages named tunnel configs for known VMs
|
|
└── Developer: ssh haskell-build → build remotely, rsync artifacts back
|
|
```
|
|
|
|
## Constraints
|
|
|
|
- Image must be fully unattended (no interactive steps after first boot).
|
|
- Credentials (SSH keys, state-hub URL) are injected via `/etc/build-agent.env`,
|
|
never baked into the OVA.
|
|
- Must work with the existing ops-bridge tunnel pattern.
|
|
- State-hub registration uses the existing `/capability-catalog/` endpoint;
|
|
no new state-hub endpoints required for MVP.
|
|
|
|
## Deliverables
|
|
|
|
| Path | Purpose |
|
|
|------|---------|
|
|
| `infra/build-machines/haskell/haskell-build.pkr.hcl` | Packer build definition (NAT during build) |
|
|
| `infra/build-machines/haskell/scripts/install-haskell.sh` | GHCup + GHC 9.8.4 + 9.6.6 install |
|
|
| `infra/build-machines/haskell/scripts/install-agent.sh` | Agent + systemd install |
|
|
| `infra/build-machines/haskell/scripts/setup-vm.sh` | Post-import VBoxManage network config |
|
|
| `infra/build-machines/haskell/scripts/inject-keys.sh` | SSH key + env injection for new VMs |
|
|
| `infra/build-machines/haskell/files/build-agent.py` | Boot registration + dual tunnel agent |
|
|
| `infra/build-machines/haskell/files/build-agent.service` | systemd unit |
|
|
| `infra/build-machines/haskell/files/build-agent.env.template` | Env var template |
|
|
| `infra/build-machines/haskell/files/cloud-init/user-data` | Ubuntu autoinstall config |
|
|
| `infra/build-machines/haskell/files/cloud-init/meta-data` | cloud-init meta-data |
|
|
| `infra/build-machines/port-registry.yml` | Port assignment tracker (12221-12230) |
|
|
| `infra/build-machines/state-hub-refs.yml` | State-hub entity UUID references |
|
|
| `infra/build-machines/README.md` | Deployment & usage guide |
|
|
|
|
---
|
|
|
|
## Phase 1 — Packer Image Build Infrastructure
|
|
|
|
### Task: Packer build definition
|
|
|
|
```task
|
|
id: CUST-WP-0032-T01
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "1430844c-82f2-4e7b-88b2-6e74a29167c4"
|
|
```
|
|
|
|
Create `infra/build-machines/haskell/haskell-build.pkr.hcl`.
|
|
|
|
The Packer `virtualbox-iso` source must:
|
|
- Base: Ubuntu 24.04 LTS server ISO (amd64)
|
|
- Disk: 40 GB, Memory: 8192 MB, CPUs: 4
|
|
- **Network during build: NAT** — Packer needs internet for ISO + packages; bridged
|
|
is set post-import by `setup-vm.sh` (adapter names are laptop-specific)
|
|
- Unattended install via cloud-init autoinstall (not preseed)
|
|
- SSH communicator: `build` user, key-based after provisioning
|
|
- Provisioners: `install-haskell.sh`, then `install-agent.sh`
|
|
- Post-processor: export as `haskell-build-<date>.ova`
|
|
|
|
Key Packer variables to expose:
|
|
- `var.vm_name` (default: `haskell-build`)
|
|
- `var.disk_size` (default: `40960`)
|
|
- `var.memory` (default: `8192`)
|
|
- `var.cpus` (default: `4`)
|
|
- `var.ghc_primary_version` (default: `9.8.4`)
|
|
- `var.ghc_secondary_version` (default: `9.6.6`)
|
|
- `var.cabal_version` (default: `3.12.1.0`)
|
|
|
|
Also create `scripts/setup-vm.sh` (run once after OVA import):
|
|
```bash
|
|
#!/bin/bash
|
|
# setup-vm.sh — switches imported VM from NAT to bridged networking
|
|
VM_NAME="${1:?Usage: setup-vm.sh <vm-name> [adapter]}"
|
|
# Auto-detect first available bridge interface if not specified
|
|
ADAPTER="${2:-$(VBoxManage list bridgedifs | awk '/^Name:/{print $2; exit}')}"
|
|
|
|
VBoxManage modifyvm "$VM_NAME" \
|
|
--nic1 bridged \
|
|
--bridgeadapter1 "$ADAPTER" \
|
|
--memory 8192 --cpus 4
|
|
|
|
echo "Configured $VM_NAME: bridged on $ADAPTER"
|
|
echo "Next: inject keys with scripts/inject-keys.sh, then start VM"
|
|
```
|
|
|
|
### Task: Ubuntu autoinstall (cloud-init) config
|
|
|
|
```task
|
|
id: CUST-WP-0032-T02
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "816bd164-ed1a-4d57-bdeb-c9e3d9e4d614"
|
|
```
|
|
|
|
Create `files/cloud-init/user-data` (cloud-init autoinstall format):
|
|
- Locale: en_US.UTF-8, keyboard: us
|
|
- Storage: LVM, single volume group, auto-resize
|
|
- User: `build` (sudo NOPASSWD), password disabled, SSH key placeholder
|
|
- Packages: `build-essential curl git libgmp-dev libffi-dev zlib1g-dev
|
|
libncurses-dev libtinfo-dev pkg-config openssh-server autossh jq rsync`
|
|
- SSH: `PasswordAuthentication no`, `PubkeyAuthentication yes`
|
|
- Timezone: Europe/Berlin
|
|
|
|
Create `files/cloud-init/meta-data` (empty YAML, required by autoinstall).
|
|
|
|
Packer boot_command sequence:
|
|
1. Select "Try or Install Ubuntu Server"
|
|
2. Wait for installer, interrupt with autoinstall kernel param
|
|
3. Feed `ds=nocloud;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/` via Packer HTTP server
|
|
|
|
### Task: Haskell toolchain installation script
|
|
|
|
```task
|
|
id: CUST-WP-0032-T03
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "2900ae95-828b-4ced-8821-ded6b4a52e61"
|
|
```
|
|
|
|
Create `scripts/install-haskell.sh`:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
DEBIAN_FRONTEND=noninteractive
|
|
|
|
# System deps (already installed via cloud-init but idempotent)
|
|
apt-get update -qq
|
|
apt-get install -y -qq build-essential curl git \
|
|
libgmp-dev libffi-dev zlib1g-dev libncurses-dev libtinfo-dev pkg-config
|
|
|
|
# GHCup — non-interactive bootstrap
|
|
# Primary version (9.8.4) is the default; secondary (9.6.6) covers LTS 22/23.
|
|
# Skip Stack (cabal covers 95% of projects) and HLS (saves ~2 GB image size).
|
|
GHC_PRIMARY="${GHC_PRIMARY_VERSION:-9.8.4}"
|
|
GHC_SECONDARY="${GHC_SECONDARY_VERSION:-9.6.6}"
|
|
CABAL_VERSION="${CABAL_VERSION:-3.12.1.0}"
|
|
|
|
export BOOTSTRAP_HASKELL_NONINTERACTIVE=1
|
|
export BOOTSTRAP_HASKELL_GHC_VERSION="$GHC_PRIMARY"
|
|
export BOOTSTRAP_HASKELL_CABAL_VERSION="$CABAL_VERSION"
|
|
export BOOTSTRAP_HASKELL_INSTALL_STACK=0 # not needed; cabal suffices
|
|
export BOOTSTRAP_HASKELL_INSTALL_HLS=0 # ~2 GB — skip for build-only image
|
|
|
|
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \
|
|
| runuser -l build -c 'sh -s -- --no-modify-path'
|
|
|
|
# Add ghcup env to build user profile
|
|
echo '. "$HOME/.ghcup/env"' >> /home/build/.bashrc
|
|
echo '. "$HOME/.ghcup/env"' >> /home/build/.profile
|
|
|
|
# Install secondary GHC version (~500 MB, shared GHCup base — worth it)
|
|
runuser -l build -c "source ~/.ghcup/env && ghcup install ghc $GHC_SECONDARY"
|
|
|
|
# Ensure primary is the default
|
|
runuser -l build -c "source ~/.ghcup/env && ghcup set ghc $GHC_PRIMARY"
|
|
|
|
# Pre-warm cabal package db (saves 2-3 min on first real build)
|
|
runuser -l build -c 'source ~/.ghcup/env && cabal update'
|
|
|
|
# Verify both versions present
|
|
runuser -l build -c "source ~/.ghcup/env && ghc --version && cabal --version"
|
|
runuser -l build -c "source ~/.ghcup/env && ghcup run --ghc $GHC_SECONDARY -- ghc --version"
|
|
```
|
|
|
|
### Task: Agent installation script
|
|
|
|
```task
|
|
id: CUST-WP-0032-T04
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "5267d2f3-f8fb-4072-a9fa-40b18cf888bd"
|
|
```
|
|
|
|
Create `scripts/install-agent.sh`:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Copy agent artefacts (provisioned by Packer file provisioner)
|
|
install -m 0755 /tmp/build-agent.py /usr/local/bin/build-agent
|
|
install -m 0644 /tmp/build-agent.service /etc/systemd/system/build-agent.service
|
|
install -m 0600 /tmp/build-agent.env.template /etc/build-agent.env.template
|
|
|
|
# Placeholder env file — operator fills this in before first boot
|
|
if [ ! -f /etc/build-agent.env ]; then
|
|
cp /etc/build-agent.env.template /etc/build-agent.env
|
|
fi
|
|
|
|
# Install autossh
|
|
apt-get install -y -qq autossh
|
|
|
|
# Enable agent service (starts on boot, after network-online)
|
|
systemctl daemon-reload
|
|
systemctl enable build-agent.service
|
|
|
|
# SSH host key generation (deterministic at first boot, not baked in image)
|
|
dpkg-reconfigure openssh-server
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2 — Boot-time Registration Agent
|
|
|
|
### Task: Registration + tunnel agent (build-agent.py)
|
|
|
|
```task
|
|
id: CUST-WP-0032-T05
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "18ee959d-30b7-4a06-9a84-02c4e5d7ba83"
|
|
```
|
|
|
|
Create `files/build-agent.py`. Full implementation:
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""
|
|
build-agent — runs at VM boot.
|
|
1. Reads /etc/build-agent.env
|
|
2. Detects GHC version
|
|
3. Registers (or updates) a capability-catalog entry in the state-hub
|
|
4. Opens an autossh reverse tunnel to the workstation
|
|
"""
|
|
import os, json, socket, subprocess, time, sys
|
|
import urllib.request, urllib.error
|
|
|
|
def load_env(path="/etc/build-agent.env"):
|
|
env = {}
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith('#') and '=' in line:
|
|
k, _, v = line.partition('=')
|
|
env[k.strip()] = v.strip().strip('"')
|
|
except FileNotFoundError:
|
|
pass
|
|
return env
|
|
|
|
def get_ghc_version():
|
|
for path in [
|
|
"/home/build/.ghcup/bin/ghc",
|
|
"/usr/local/bin/ghc",
|
|
]:
|
|
try:
|
|
r = subprocess.run([path, "--version"],
|
|
capture_output=True, text=True, timeout=15)
|
|
if r.returncode == 0:
|
|
return r.stdout.strip().split()[-1]
|
|
except Exception:
|
|
continue
|
|
return "unknown"
|
|
|
|
def get_local_ip():
|
|
"""Get the primary LAN IP (not loopback)."""
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
return s.getsockname()[0]
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
def register(cfg):
|
|
# State-hub is always accessed via the forward tunnel (port 18000), never
|
|
# via direct LAN. This matches the CoulombCore remote worker pattern and
|
|
# works regardless of network topology (LAN, VPN, different subnet).
|
|
state_hub = cfg.get("STATE_HUB_URL", "http://127.0.0.1:18000")
|
|
hostname = socket.gethostname()
|
|
domain = cfg.get("STATE_HUB_DOMAIN", "railiance")
|
|
remote_port = cfg.get("REMOTE_PORT", "12222")
|
|
ghc_ver = get_ghc_version()
|
|
local_ip = get_local_ip()
|
|
|
|
payload = {
|
|
"domain": domain,
|
|
"capability_type": "haskell-build-agent",
|
|
"title": f"Haskell Build Agent — {hostname}",
|
|
"description": (
|
|
f"GHC {ghc_ver} build sandbox on {hostname} ({local_ip}). "
|
|
f"SSH tunnel port: {remote_port} on workstation."
|
|
),
|
|
"keywords": [
|
|
"haskell", "ghc", f"ghc-{ghc_ver}",
|
|
"build-agent", "cabal", "stack",
|
|
f"host:{hostname}", f"tunnel-port:{remote_port}",
|
|
],
|
|
}
|
|
|
|
data = json.dumps(payload).encode()
|
|
req = urllib.request.Request(
|
|
f"{state_hub}/capability-catalog/",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
result = json.loads(resp.read())
|
|
print(f"[build-agent] Registered capability: {result['id']}", flush=True)
|
|
return result
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode()
|
|
print(f"[build-agent] Registration HTTP error {e.code}: {body}", flush=True)
|
|
raise
|
|
except Exception as e:
|
|
print(f"[build-agent] Registration failed: {e}", flush=True)
|
|
raise
|
|
|
|
def open_tunnel(cfg):
|
|
relay_host = cfg.get("SSH_RELAY_HOST", "")
|
|
relay_user = cfg.get("SSH_RELAY_USER", "worsch")
|
|
ssh_key = cfg.get("SSH_KEY_PATH", "/home/build/.ssh/id_build")
|
|
remote_port = cfg.get("REMOTE_PORT", "12222")
|
|
|
|
if not relay_host:
|
|
print("[build-agent] SSH_RELAY_HOST not set — tunnel disabled", flush=True)
|
|
# Sleep forever so systemd considers service active
|
|
while True:
|
|
time.sleep(3600)
|
|
|
|
cmd = [
|
|
"autossh",
|
|
"-M", "0", # disable autossh monitoring port
|
|
"-o", "ServerAliveInterval=30",
|
|
"-o", "ServerAliveCountMax=3",
|
|
"-o", "ExitOnForwardFailure=yes",
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-N",
|
|
"-R", f"{remote_port}:localhost:22", # reverse: workstation → VM SSH
|
|
"-L", "18000:localhost:8000", # forward: VM → state-hub (port 18000)
|
|
"-i", ssh_key,
|
|
f"{relay_user}@{relay_host}",
|
|
]
|
|
print(
|
|
f"[build-agent] Opening tunnels: "
|
|
f"-R {remote_port}→local:22, -L 18000→state-hub:8000",
|
|
flush=True,
|
|
)
|
|
subprocess.run(cmd) # autossh manages reconnects internally
|
|
|
|
def main():
|
|
cfg = load_env()
|
|
|
|
# Retry registration until state-hub is reachable (network may not be ready)
|
|
for attempt in range(20):
|
|
try:
|
|
register(cfg)
|
|
break
|
|
except Exception:
|
|
wait = min(10 * (attempt + 1), 60)
|
|
print(f"[build-agent] Retrying in {wait}s ...", flush=True)
|
|
time.sleep(wait)
|
|
else:
|
|
print("[build-agent] Registration permanently failed — continuing to tunnel",
|
|
flush=True)
|
|
|
|
open_tunnel(cfg)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
### Task: systemd unit and env template
|
|
|
|
```task
|
|
id: CUST-WP-0032-T06
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "1a6bf2a2-91e8-46f9-a82c-de08ccfda729"
|
|
```
|
|
|
|
Create `files/build-agent.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=Haskell Build Agent — State Hub registration + SSH reverse tunnel
|
|
Documentation=https://github.com/tegwick/the-custodian
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=build
|
|
EnvironmentFile=/etc/build-agent.env
|
|
ExecStart=/usr/local/bin/build-agent
|
|
Restart=on-failure
|
|
RestartSec=30
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=build-agent
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
Create `files/build-agent.env.template`:
|
|
|
|
```bash
|
|
# Custodian State Hub URL — always access via forward tunnel (port 18000).
|
|
# The agent opens -L 18000:localhost:8000 alongside the reverse SSH tunnel,
|
|
# so this works regardless of network topology (LAN, VPN, different subnet).
|
|
# Matches the CoulombCore remote worker bridge pattern.
|
|
STATE_HUB_URL=http://127.0.0.1:18000
|
|
|
|
# Domain to register capability under
|
|
STATE_HUB_DOMAIN=railiance
|
|
|
|
# Workstation hostname or LAN IP for SSH relay connection
|
|
# The VM connects OUT to this host to establish both tunnels.
|
|
SSH_RELAY_HOST=192.168.1.100 # replace with actual workstation LAN IP
|
|
SSH_RELAY_USER=worsch
|
|
|
|
# Path to private key for SSH tunnel (matching authorized_keys on workstation)
|
|
SSH_KEY_PATH=/home/build/.ssh/id_build
|
|
|
|
# Port to bind on workstation (ssh -R <REMOTE_PORT>:localhost:22)
|
|
# Each VM instance must use a distinct port — see port-registry.yml
|
|
# Range: 12221-12230
|
|
REMOTE_PORT=12222
|
|
```
|
|
|
|
### Task: SSH key injection procedure
|
|
|
|
```task
|
|
id: CUST-WP-0032-T07
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "6bb36de9-df03-452d-bb1c-3dfc5a695265"
|
|
```
|
|
|
|
Document and script the key injection procedure for new VM deployments.
|
|
Keys must never be baked into the OVA. Injection options (in order of preference):
|
|
|
|
**Option A — VirtualBox shared folder (recommended for LAN deployments):**
|
|
```bash
|
|
# On host before first boot:
|
|
vboxmanage sharedfolder add "haskell-build" \
|
|
--name "build-init" \
|
|
--hostpath /path/to/vm-keys/$(vm_name) \
|
|
--readonly --auto-mount
|
|
|
|
# VM firstboot service reads from /media/sf_build-init/:
|
|
# id_build (private key), id_build.pub, build-agent.env
|
|
```
|
|
|
|
**Option B — Post-boot SSH + scp:**
|
|
```bash
|
|
# VM starts with password auth enabled for initial setup only
|
|
ssh build@<vm-ip> "mkdir -p ~/.ssh && chmod 700 ~/.ssh"
|
|
scp id_build build@<vm-ip>:~/.ssh/
|
|
ssh build@<vm-ip> "chmod 600 ~/.ssh/id_build"
|
|
sudo scp build-agent.env build@<vm-ip>:/etc/build-agent.env
|
|
```
|
|
|
|
Create `scripts/inject-keys.sh` implementing Option B.
|
|
|
|
---
|
|
|
|
## Phase 3 — SSH Bridge Integration
|
|
|
|
### Task: Workstation-side tunnel configuration
|
|
|
|
```task
|
|
id: CUST-WP-0032-T08
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "a42342cb-41ef-4915-8ce5-923a36bd2918"
|
|
```
|
|
|
|
For each deployed VM, add an entry to the ops-bridge tunnel catalog so the
|
|
workstation knows about the reverse-tunnel port and can health-check it.
|
|
|
|
Ops-bridge config entry (`~/ops-bridge/tunnels/haskell-build-<name>.yml`):
|
|
```yaml
|
|
name: haskell-build-alpha
|
|
description: Haskell Build VM — primary laptop
|
|
direction: reverse # VM → workstation
|
|
remote_port: 12222
|
|
local_port: 22
|
|
target_host: localhost
|
|
check_cmd: "ssh -p 12222 -o StrictHostKeyChecking=no build@localhost exit"
|
|
auto_restart: false # VM controls the tunnel
|
|
```
|
|
|
|
Add a `make bridge-status` target to `infra/build-machines/Makefile` that
|
|
queries known build-machine ports:
|
|
```makefile
|
|
bridge-status:
|
|
@for port in 12221 12222 12223 12224 12225; do \
|
|
ssh -q -p $$port -o ConnectTimeout=2 -o StrictHostKeyChecking=no \
|
|
build@localhost "echo $$port: $$(hostname) OK" 2>/dev/null || \
|
|
echo "$$port: no tunnel"; \
|
|
done
|
|
```
|
|
|
|
### Task: SSH client config template
|
|
|
|
```task
|
|
id: CUST-WP-0032-T09
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "5ea059a0-94c3-4b6e-ae99-cf20a6c4af1c"
|
|
```
|
|
|
|
Create `infra/build-machines/ssh-config.template`:
|
|
```
|
|
# Haskell Build VM — tunnel via workstation (auto-generated)
|
|
# Source: infra/build-machines/README.md
|
|
Host haskell-build haskell-build-alpha
|
|
HostName localhost
|
|
Port 12222
|
|
User build
|
|
IdentityFile ~/.ssh/id_build
|
|
StrictHostKeyChecking no
|
|
UserKnownHostsFile /dev/null
|
|
ServerAliveInterval 30
|
|
ServerAliveCountMax 3
|
|
```
|
|
|
|
Add `make install-ssh-config` target that appends this to `~/.ssh/config`
|
|
(idempotent via sentinel comment check).
|
|
|
|
### Task: Port allocation registry
|
|
|
|
```task
|
|
id: CUST-WP-0032-T10
|
|
status: done
|
|
priority: low
|
|
state_hub_task_id: "6bfd43de-b1e2-4114-b509-d1c78d066756"
|
|
```
|
|
|
|
Create `infra/build-machines/port-registry.yml` to track port assignments
|
|
and prevent collisions across multiple VM instances:
|
|
|
|
```yaml
|
|
# Build machine port registry
|
|
# Range: 12221-12230 (10 slots)
|
|
# Each entry: port, vm_name, host_machine, status
|
|
|
|
ports:
|
|
12221:
|
|
vm_name: haskell-build-alpha
|
|
host_machine: unassigned
|
|
status: reserved
|
|
12222:
|
|
vm_name: haskell-build-beta
|
|
host_machine: unassigned
|
|
status: reserved
|
|
12223:
|
|
vm_name: unassigned
|
|
status: available
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4 — State-Hub Capability Registration
|
|
|
|
### Task: Bootstrap capability catalog entry
|
|
|
|
```task
|
|
id: CUST-WP-0032-T11
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "f7efd28e-0ae4-41c9-bd76-649bd17bec16"
|
|
```
|
|
|
|
Register the `haskell-build-agent` capability type in the state-hub capability
|
|
catalog with a canonical/static entry (distinct from the per-instance dynamic
|
|
entries that VMs create on boot).
|
|
|
|
```bash
|
|
curl -s -X POST http://127.0.0.1:8000/capability-catalog/ \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"domain": "railiance",
|
|
"capability_type": "haskell-build-agent",
|
|
"title": "Haskell Build Agent (type definition)",
|
|
"description": "Capability type for GHC/Cabal/Stack build sandboxes running in VirtualBox VMs. Instances register themselves at boot via build-agent.py. SSH access via reverse tunnel on workstation port range 12221-12230.",
|
|
"keywords": ["haskell", "ghc", "cabal", "stack", "build-agent", "virtualbox", "sandbox"]
|
|
}'
|
|
```
|
|
|
|
Save the returned `id` in `infra/build-machines/state-hub-refs.yml`:
|
|
```yaml
|
|
# State-hub entity references for build-machines infra
|
|
capability_type_entry_id: "<uuid-returned>"
|
|
```
|
|
|
|
### Task: Query interface via state-hub MCP
|
|
|
|
```task
|
|
id: CUST-WP-0032-T12
|
|
status: done
|
|
priority: low
|
|
state_hub_task_id: "55f30877-7fe7-4aaa-b74a-f9ab435f1d9a"
|
|
```
|
|
|
|
Verify that registered build agents are discoverable via existing MCP tools:
|
|
```python
|
|
# Should return all haskell-build-agent entries
|
|
list_capabilities(capability_type="haskell-build-agent")
|
|
# or
|
|
get_capability_profile(domain="railiance")
|
|
```
|
|
|
|
If filtering by `capability_type` is not supported, document the workaround
|
|
(filter client-side after `list_capabilities()`). No new MCP tools needed for MVP.
|
|
|
|
---
|
|
|
|
## Phase 5 — Remote Build Workflow
|
|
|
|
### Task: Remote build Makefile
|
|
|
|
```task
|
|
id: CUST-WP-0032-T13
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "4c27f5db-a0c1-4f43-97a7-87472ce3a1cc"
|
|
```
|
|
|
|
Create `infra/build-machines/Makefile` with targets for using the build VM:
|
|
|
|
```makefile
|
|
# infra/build-machines/Makefile
|
|
# Usage: make remote-build PROJECT=~/projects/my-haskell-app [VM=haskell-build]
|
|
|
|
VM ?= haskell-build
|
|
PROJECT ?= .
|
|
RDIR := /build/$(notdir $(realpath $(PROJECT)))
|
|
|
|
# Sync project source to VM (exclude build artefacts)
|
|
.PHONY: sync
|
|
sync:
|
|
rsync -av --delete \
|
|
--exclude='.git' \
|
|
--exclude='dist-newstyle' \
|
|
--exclude='.stack-work' \
|
|
--exclude='*.o' --exclude='*.hi' \
|
|
$(PROJECT)/ $(VM):$(RDIR)/
|
|
|
|
# Run cabal build on VM after sync
|
|
.PHONY: remote-build
|
|
remote-build: sync
|
|
ssh $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal build all 2>&1"
|
|
|
|
# Run tests on VM
|
|
.PHONY: remote-test
|
|
remote-test: sync
|
|
ssh $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal test all 2>&1"
|
|
|
|
# Open a GHCi session on the VM
|
|
.PHONY: remote-ghci
|
|
remote-ghci: sync
|
|
ssh -t $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal repl"
|
|
|
|
# Sync build artefacts back (for local IDE inspection)
|
|
.PHONY: fetch-artifacts
|
|
fetch-artifacts:
|
|
rsync -av $(VM):$(RDIR)/dist-newstyle/ $(PROJECT)/dist-newstyle/
|
|
|
|
# Check which VMs are reachable
|
|
.PHONY: bridge-status
|
|
bridge-status:
|
|
@echo "Scanning build-machine tunnel ports..."
|
|
@for port in 12221 12222 12223 12224 12225; do \
|
|
result=$$(ssh -q -p $$port -o ConnectTimeout=2 \
|
|
-o StrictHostKeyChecking=no build@localhost \
|
|
"echo $$port OK: $$(hostname) — GHC: $$(~/.ghcup/bin/ghc --numeric-version)" \
|
|
2>/dev/null) ; \
|
|
if [ -n "$$result" ]; then echo " $$result"; \
|
|
else echo " port $$port: no tunnel"; fi; \
|
|
done
|
|
|
|
.PHONY: vm-info
|
|
vm-info:
|
|
ssh $(VM) "uname -a; source ~/.ghcup/env && ghc --version && cabal --version"
|
|
```
|
|
|
|
### Task: End-to-end smoke test
|
|
|
|
```task
|
|
id: CUST-WP-0032-T14
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "b3a9613d-0d08-4f08-9361-d7e42c07069a"
|
|
```
|
|
|
|
Write and run a smoke test that validates the full stack:
|
|
|
|
1. Boot VM (manually or via VBoxManage)
|
|
2. Wait for tunnel port to appear: `make bridge-status`
|
|
3. Check state-hub capability catalog: `GET /capability-catalog/?capability_type=haskell-build-agent`
|
|
4. Verify VM entry appeared with correct hostname and tunnel port in keywords
|
|
5. Build a small Haskell project: `make remote-build PROJECT=/path/to/hello-world`
|
|
6. Confirm artefacts compile: `make fetch-artifacts`
|
|
|
|
Create `infra/build-machines/smoke-test.sh` that automates steps 2-5.
|
|
|
|
### Task: Deployment README
|
|
|
|
```task
|
|
id: CUST-WP-0032-T15
|
|
status: done
|
|
priority: medium
|
|
state_hub_task_id: "4d858d77-4b9d-4f75-820a-b8f9d2dd3f19"
|
|
```
|
|
|
|
Create `infra/build-machines/README.md` covering:
|
|
- Prerequisites (Packer ≥ 1.10, VirtualBox ≥ 7.0, autossh on workstation)
|
|
- One-time setup: generating the build SSH keypair
|
|
- Building the OVA: `cd infra/build-machines/haskell && packer build .`
|
|
- Deploying a VM: import OVA → inject keys → set bridged adapter → start
|
|
- Adding to ops-bridge tunnel catalog
|
|
- Using the VM: `make remote-build`, `make bridge-status`
|
|
- Updating the VM: re-run Packer, re-import (data lives on workstation)
|
|
- Troubleshooting: tunnel not up, capability not appearing in state-hub
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
None — this workplan is self-contained. Packer and VirtualBox are workstation-level
|
|
tools that need to be installed once.
|
|
|
|
## Decisions (Resolved 2026-04-20)
|
|
|
|
1. **Host network adapter name** → **Post-import via `setup-vm.sh`** (VBoxManage
|
|
auto-detects first available bridge interface). Packer builds with NAT for internet
|
|
access; `setup-vm.sh` switches to bridged after OVA import. Avoids hardcoding any
|
|
laptop-specific adapter name in the image.
|
|
|
|
2. **GHC version pinning** → **Bake two versions: 9.8.4 (primary) + 9.6.6 (secondary)**.
|
|
Both installed at image build time via GHCup. 9.8.4 is the default; 9.6.6 covers
|
|
Stackage LTS 22/23 projects at a cost of ~500 MB extra image size. Additional
|
|
versions can be added post-deployment with `ghcup install ghc <ver>` — no rebuild.
|
|
|
|
3. **Stack vs Cabal** → **Cabal only, no Stack, no HLS**. Cabal covers 95%+ of
|
|
Haskell projects. Stack adds ~400 MB and HLS adds ~2 GB to the image with no
|
|
benefit for a CI/build sandbox. Both can be added post-deployment if a project
|
|
specifically requires them.
|
|
|
|
4. **state-hub reachability from VM** → **Always via forward tunnel (port 18000)**.
|
|
The agent opens `-L 18000:localhost:8000` alongside `-R <port>:localhost:22` in a
|
|
single autossh connection. `STATE_HUB_URL` defaults to `http://127.0.0.1:18000`.
|
|
This matches the CoulombCore remote worker bridge pattern and works on any network
|
|
topology without exposing state-hub on a non-loopback interface.
|