Files
the-custodian/workplans/CUST-WP-0032-haskell-build-machine.md
tegwick 9bc761c2b5 feat(railiance): implement CUST-WP-0032 Haskell build machine infra
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>
2026-04-20 12:01:30 +02:00

25 KiB

id, type, title, domain, repo, status, owner, topic_slug, created, updated, decisions_resolved, state_hub_workstream_id
id type title domain repo status owner topic_slug created updated decisions_resolved state_hub_workstream_id
CUST-WP-0032 workplan Haskell Build Machine — VirtualBox Image & State-Hub Integration railiance the-custodian done custodian railiance 2026-04-20 2026-04-20 2026-04-20 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

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):

#!/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

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

id: CUST-WP-0032-T03
status: done
priority: high
state_hub_task_id: "2900ae95-828b-4ced-8821-ded6b4a52e61"

Create scripts/install-haskell.sh:

#!/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

id: CUST-WP-0032-T04
status: done
priority: high
state_hub_task_id: "5267d2f3-f8fb-4072-a9fa-40b18cf888bd"

Create scripts/install-agent.sh:

#!/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)

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:

#!/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

id: CUST-WP-0032-T06
status: done
priority: high
state_hub_task_id: "1a6bf2a2-91e8-46f9-a82c-de08ccfda729"

Create files/build-agent.service:

[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:

# 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

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):

# 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:

# 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

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):

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:

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

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

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:

# 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

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).

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:

# State-hub entity references for build-machines infra
capability_type_entry_id: "<uuid-returned>"

Task: Query interface via state-hub MCP

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:

# 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

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:

# 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

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

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 namePost-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 pinningBake 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 CabalCabal 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 VMAlways 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.