From e74ebb089b2c99edeb37c359f10895d293307e70 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:24:25 -0500 Subject: [PATCH] feat(miner): add a fleet-mode AMS host Terraform starter module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provision a dedicated, firewalled Hetzner Cloud host for running gittensory-miner as an always-on fleet-mode CLI worker (vs laptop mode): a single Ubuntu VM with a persistent volume mounted at /data/miner, so the attempt log, prediction ledger, and all other local stores survive re-provisioning. - SSH-only inbound (no public endpoints) — the CLI-worker profile, distinct from the root terraform/ ORB server module, which is left untouched. - Docker installed from Ubuntu's own docker.io package via cloud-init (no third-party apt repo, no piped remote install scripts). - cloud-init polls for the attached volume's block device before mounting, so first boot can never leave /data/miner silently backed by the root disk. - Modest CLI-worker default sizing (cx22, 10 GB); mirrors the root module's Hetzner provider and conventions for consistency. Fixes #5183 --- packages/gittensory-miner/terraform/README.md | 63 ++++++++++ packages/gittensory-miner/terraform/main.tf | 102 +++++++++++++++ .../gittensory-miner/terraform/outputs.tf | 24 ++++ .../gittensory-miner/terraform/variables.tf | 39 ++++++ test/unit/miner-terraform-module.test.ts | 116 ++++++++++++++++++ 5 files changed, 344 insertions(+) create mode 100644 packages/gittensory-miner/terraform/README.md create mode 100644 packages/gittensory-miner/terraform/main.tf create mode 100644 packages/gittensory-miner/terraform/outputs.tf create mode 100644 packages/gittensory-miner/terraform/variables.tf create mode 100644 test/unit/miner-terraform-module.test.ts diff --git a/packages/gittensory-miner/terraform/README.md b/packages/gittensory-miner/terraform/README.md new file mode 100644 index 000000000..e78a60aca --- /dev/null +++ b/packages/gittensory-miner/terraform/README.md @@ -0,0 +1,63 @@ +# Fleet-mode AMS host — Terraform starter module + +A minimal, self-contained Terraform module that provisions a dedicated **fleet-mode** host for the Autonomous +Miner System (AMS / `gittensory-miner`) on Hetzner Cloud — for operators who want the miner running as an +always-on CLI worker instead of in laptop mode. + +It stands up a single firewalled VM with Docker pre-installed and a persistent volume mounted at `/data/miner`, +then gets out of the way. It is **not** the root [`terraform/`](../../../terraform/) module, which provisions the +multi-tenant ORB server (persistent HTTP service behind Caddy); this module exposes **no public endpoints** — +the miner only makes outbound calls, so the sole inbound rule is SSH. + +## What it creates + +| Resource | Purpose | +| ------------------------- | ------------------------------------------------------------------------------------------- | +| `hcloud_server` | One Ubuntu 24.04 VM, CLI-worker sized (`server_type` default `cx22` = 2 vCPU / 4 GB) | +| `hcloud_firewall` | Inbound **SSH only**, scoped to `admin_ip_allowlist` — no 80/443/app ports | +| `hcloud_volume` (+ attach)| Persistent ext4 volume mounted at `/data/miner` so all local stores survive re-provisioning | +| `hcloud_ssh_key` | Your SSH public key, for access | + +Docker is installed on first boot via cloud-init user-data (no manual provisioner step). + +## Prerequisites + +- [Terraform](https://developer.hashicorp.com/terraform/install) `>= 1.6` +- A Hetzner Cloud project + API token (console.hetzner.cloud → Security → API Tokens) +- An SSH key pair + +## Usage + +```sh +cd packages/gittensory-miner/terraform + +export TF_VAR_hcloud_token="…" # or set it in a *.tfvars file (never commit it) +terraform init +terraform plan -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" +terraform apply -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" +``` + +Useful variables (see [`variables.tf`](variables.tf) for all): `server_type`, `location`, `volume_size_gb`, +`admin_ip_allowlist` (restrict this to your IP in production). + +## After apply — start the miner + +The module provisions the **host**; you finish the miner setup over SSH (secrets never live in Terraform state): + +1. `terraform output ssh_command` → SSH in. +2. Create a `.gittensory-miner.env` with your `GITHUB_TOKEN` and coding-agent provider credentials — see + [`../.gittensory-miner.env.example`](../.gittensory-miner.env.example). +3. Run the miner container against the mounted volume using the existing + [`../docker-compose.miner.yml`](../docker-compose.miner.yml) (its state mount is already pinned to + `/data/miner`, which `terraform output data_mount` confirms). Full run/upgrade guidance lives in + [`../DEPLOYMENT.md`](../DEPLOYMENT.md). + +## Outputs + +| Output | Description | +| --------------- | ----------------------------------------------------------------------- | +| `server_ipv4` | Public IPv4 of the host | +| `server_ipv6` | Public IPv6 of the host | +| `ssh_command` | Ready-to-run SSH command | +| `volume_device` | Block device path for the data volume | +| `data_mount` | `/data/miner` — the miner's `GITTENSORY_MINER_CONFIG_DIR`; the run mount | diff --git a/packages/gittensory-miner/terraform/main.tf b/packages/gittensory-miner/terraform/main.tf new file mode 100644 index 000000000..523c5ca5d --- /dev/null +++ b/packages/gittensory-miner/terraform/main.tf @@ -0,0 +1,102 @@ +# Terraform starter module for a dedicated fleet-mode AMS (Autonomous Miner System) host on Hetzner Cloud. +# Provisions a single, firewalled VM with Docker pre-installed via cloud-init and a persistent volume mounted at +# /data/miner (the miner's built-in GITTENSORY_MINER_CONFIG_DIR default), so the append-only attempt log, +# prediction ledger, and every other local store survive instance recreation. +# +# This is the CLI-worker profile — it exposes NO public endpoints by default (unlike the root terraform/ module, +# which provisions the multi-tenant ORB server behind Caddy on 80/443). After `terraform apply`: SSH in, drop your +# secrets into a .gittensory-miner.env, and start the miner container against /data/miner. See README.md and +# ../docker-compose.miner.yml / ../DEPLOYMENT.md for the run step. + +terraform { + required_version = ">= 1.6" + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.49" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +# ── SSH key ──────────────────────────────────────────────────────────────────── +resource "hcloud_ssh_key" "miner" { + name = "gittensory-miner-deploy" + public_key = var.ssh_public_key +} + +# ── Firewall — CLI-worker profile: SSH in only, NO public endpoints ────────────── +# The miner makes only outbound calls (GitHub, the coding-agent provider); it serves nothing, so the sole inbound +# rule is SSH, scoped to your admin allowlist. Deliberately no 80/443/app-port rules — that is the ORB profile. +resource "hcloud_firewall" "miner" { + name = "gittensory-miner" + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.admin_ip_allowlist + } +} + +# ── Persistent volume for /data/miner (attempt log, prediction ledger, all local stores) ── +resource "hcloud_volume" "miner_data" { + name = "gittensory-miner-data" + size = var.volume_size_gb + location = var.location + format = "ext4" +} + +# ── Server ─────────────────────────────────────────────────────────────────────── +# Docker is installed from Ubuntu's own `docker.io` package (declarative `packages:` — no third-party apt repo, +# no piped remote install scripts). cloud-init mounts the data volume at /data/miner only AFTER polling for its +# block device to appear (see the `until [ -b … ]` guard below): the volume is attached by a separate +# `hcloud_volume_attachment` resource, so this post-attachment wait is what prevents a first-boot race in which +# /data/miner could otherwise be silently backed by the root disk instead of the persistent volume. +resource "hcloud_server" "miner" { + name = "gittensory-miner" + server_type = var.server_type + image = "ubuntu-24.04" + location = var.location + ssh_keys = [hcloud_ssh_key.miner.id] + firewall_ids = [hcloud_firewall.miner.id] + keep_disk = true + + user_data = <<-CLOUDINIT + #cloud-config + package_update: true + package_upgrade: true + + packages: + - docker.io + - git + - jq + + runcmd: + - systemctl enable --now docker + - usermod -aG docker ubuntu + - mkdir -p /data/miner + # Wait for the attached volume's block device before mounting, so /data/miner is always the persistent + # volume and never races the attachment on first boot. + - ["bash", "-c", "until [ -b /dev/disk/by-id/scsi-0HC_Volume_${hcloud_volume.miner_data.id} ]; do sleep 2; done"] + - mount /dev/disk/by-id/scsi-0HC_Volume_${hcloud_volume.miner_data.id} /data/miner + - echo "/dev/disk/by-id/scsi-0HC_Volume_${hcloud_volume.miner_data.id} /data/miner ext4 discard,nofail,defaults 0 0" >> /etc/fstab + - echo "cloud-init: gittensory-miner host ready — see terraform/README.md for the run step" > /var/log/gittensory-miner-init.log + CLOUDINIT + + labels = { + app = "gittensory-miner" + managed = "terraform" + } +} + +# Attach the volume to the server. The server's cloud-init polls for the resulting block device (above) before +# mounting, so the ordering between this attachment and first boot cannot leave /data/miner unbacked. +resource "hcloud_volume_attachment" "miner_data" { + server_id = hcloud_server.miner.id + volume_id = hcloud_volume.miner_data.id + automount = false +} diff --git a/packages/gittensory-miner/terraform/outputs.tf b/packages/gittensory-miner/terraform/outputs.tf new file mode 100644 index 000000000..e2d60638e --- /dev/null +++ b/packages/gittensory-miner/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "server_ipv4" { + description = "Public IPv4 address of the miner host" + value = hcloud_server.miner.ipv4_address +} + +output "server_ipv6" { + description = "Public IPv6 address of the miner host" + value = hcloud_server.miner.ipv6_address +} + +output "ssh_command" { + description = "SSH command to access the miner host" + value = "ssh ubuntu@${hcloud_server.miner.ipv4_address}" +} + +output "volume_device" { + description = "Stable by-id block device path for the /data/miner volume (attached at server creation)" + value = "/dev/disk/by-id/scsi-0HC_Volume_${hcloud_volume.miner_data.id}" +} + +output "data_mount" { + description = "Where the persistent volume is mounted — the miner's GITTENSORY_MINER_CONFIG_DIR. Point the miner container's state mount here." + value = "/data/miner" +} diff --git a/packages/gittensory-miner/terraform/variables.tf b/packages/gittensory-miner/terraform/variables.tf new file mode 100644 index 000000000..d947dea03 --- /dev/null +++ b/packages/gittensory-miner/terraform/variables.tf @@ -0,0 +1,39 @@ +variable "hcloud_token" { + description = "Hetzner Cloud API token (generate at console.hetzner.cloud → Security → API Tokens)" + type = string + sensitive = true +} + +variable "ssh_public_key" { + description = "SSH public key content for server access (e.g. file('~/.ssh/id_ed25519.pub'))" + type = string +} + +variable "server_type" { + description = "Hetzner server type — CLI-worker profile (one AMS instance running periodic attempt-runner work). cx22 = 2 vCPU / 4 GB is a modest starting point; scale up if you run a heavier coding-agent provider or higher concurrency. This is deliberately not ORB's higher-capacity multi-tenant sizing." + type = string + default = "cx22" +} + +variable "location" { + description = "Hetzner datacenter location: nbg1 (Nuremberg), fsn1 (Falkenstein), hel1 (Helsinki), ash (Ashburn VA), sin (Singapore)" + type = string + default = "nbg1" +} + +variable "volume_size_gb" { + description = "Size of the persistent /data/miner volume in GB. The local stores (attempt log, prediction ledger, plan/claim/portfolio/event/governor ledgers) are small SQLite files; 10 GB is ample for a single worker — grow it only if you retain a long attempt history." + type = number + default = 10 + + validation { + condition = var.volume_size_gb >= 10 + error_message = "Hetzner Cloud volumes have a 10 GB minimum; set volume_size_gb to 10 or more." + } +} + +variable "admin_ip_allowlist" { + description = "CIDR ranges allowed to SSH in. The miner exposes no inbound services, so this governs SSH only. Restrict to your IP(s) in production." + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} diff --git a/test/unit/miner-terraform-module.test.ts b/test/unit/miner-terraform-module.test.ts new file mode 100644 index 000000000..4df83eae6 --- /dev/null +++ b/test/unit/miner-terraform-module.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +// Static structural checks for the fleet-mode AMS Terraform starter module (#5183). `terraform validate`/`fmt` +// prove the HCL is syntactically valid + well-formed; these assertions lock in the SAFETY-CRITICAL invariants a +// syntax check can't see — chiefly "no public inbound by default" and "state lands on the persistent /data/miner +// volume" — so a future edit can't silently regress them. + +const DIR = "packages/gittensory-miner/terraform"; +const mainTf = readFileSync(`${DIR}/main.tf`, "utf8"); +const variablesTf = readFileSync(`${DIR}/variables.tf`, "utf8"); +const outputsTf = readFileSync(`${DIR}/outputs.tf`, "utf8"); +const readme = readFileSync(`${DIR}/README.md`, "utf8"); + +describe("gittensory-miner fleet-mode Terraform module (#5183)", () => { + it("uses the same Hetzner Cloud provider as the root module (consistency)", () => { + expect(mainTf).toMatch(/source\s*=\s*"hetznercloud\/hcloud"/); + expect(mainTf).toMatch(/provider\s+"hcloud"/); + expect(mainTf).toMatch(/required_version\s*=\s*">= 1\.6"/); + }); + + it("INVARIANT: exposes NO public inbound endpoints — the only inbound firewall rule is SSH", () => { + // Every inbound rule must be port 22. No 80/443/8787 or any other public port. + const inboundPorts = [ + ...mainTf.matchAll(/direction\s*=\s*"in"[\s\S]*?port\s*=\s*"(\d+)"/g), + ].map((m) => m[1]); + expect(inboundPorts.length).toBeGreaterThan(0); + expect(inboundPorts).toEqual(inboundPorts.filter((p) => p === "22")); + // SSH is scoped to the admin allowlist, never hardcoded open. + expect(mainTf).toMatch( + /port\s*=\s*"22"[\s\S]*?source_ips\s*=\s*var\.admin_ip_allowlist/, + ); + // Guard against the ORB profile leaking in. + for (const publicPort of [ + 'port = "80"', + 'port = "443"', + 'port = "8787"', + ]) { + expect(mainTf).not.toContain(publicPort); + } + }); + + it("mounts the persistent volume at /data/miner without a first-boot attach race", () => { + expect(mainTf).toMatch(/resource\s+"hcloud_volume"\s+"miner_data"/); + expect(mainTf).toMatch( + /resource\s+"hcloud_volume_attachment"\s+"miner_data"/, + ); + // RACE FIX (#5542): cloud-init polls for the attached volume's block device (post-attachment mechanism) BEFORE + // mounting, so /data/miner can never be silently backed by the root disk on first boot. + expect(mainTf).toMatch( + /until \[ -b \/dev\/disk\/by-id\/scsi-0HC_Volume_\$\{hcloud_volume\.miner_data\.id\} \]; do sleep 2; done/, + ); + expect(mainTf).toContain( + "mount /dev/disk/by-id/scsi-0HC_Volume_${hcloud_volume.miner_data.id} /data/miner", + ); + expect(mainTf).toContain("/data/miner ext4"); + expect(outputsTf).toContain("/data/miner"); + }); + + it("installs Docker via the distro package in cloud-init — no remote-fetch-and-execute", () => { + expect(mainTf).toContain("#cloud-config"); + expect(mainTf).toMatch(/user_data\s*=\s*<<-CLOUDINIT/); + // Docker from Ubuntu's own signed `docker.io` package (declarative `packages:`), not a piped install script. + expect(mainTf).toMatch(/packages:[\s\S]*?- docker\.io/); + expect(mainTf).toContain("systemctl enable --now docker"); + expect(mainTf).not.toMatch(/provisioner\s+"remote-exec"/); + // SECURITY: no third-party apt repo, no `curl … | gpg`, no remote-fetch-execute in user-data. + expect(mainTf).not.toMatch(/download\.docker\.com/); + expect(mainTf).not.toMatch(/curl[\s\S]*?\|\s*gpg/); + expect(mainTf).not.toContain("docker-ce"); + }); + + it("exposes provider-credential, region, and instance-size variables so operators adapt without editing the body", () => { + for (const v of [ + "hcloud_token", + "location", + "server_type", + "volume_size_gb", + "ssh_public_key", + ]) { + expect(variablesTf).toMatch(new RegExp(`variable\\s+"${v}"`)); + } + // The credential is marked sensitive so it never prints in plan/apply output. + expect(variablesTf).toMatch( + /variable\s+"hcloud_token"[\s\S]*?sensitive\s*=\s*true/, + ); + }); + + it("defaults to a modest CLI-worker size, distinct from ORB, and validates the volume floor", () => { + expect(variablesTf).toMatch( + /variable\s+"server_type"[\s\S]*?default\s*=\s*"cx22"/, + ); + // Hetzner's 10 GB volume minimum is enforced via a validation block (both branches documented in the message). + expect(variablesTf).toMatch( + /condition\s*=\s*var\.volume_size_gb\s*>=\s*10/, + ); + }); + + it("SECURITY: contains no hardcoded secrets — credentials come only from variables", () => { + // no literal token/secret assignment anywhere in the HCL (the scanner's own rule, applied here too) + for (const tf of [mainTf, variablesTf, outputsTf]) { + expect(tf).not.toMatch( + /(token|secret|password)\s*=\s*"[A-Za-z0-9_\-]{16,}"/, + ); + } + expect(mainTf).toMatch(/token\s*=\s*var\.hcloud_token/); + }); + + it("documents prerequisites, the init/plan/apply flow, and how outputs map into AMS setup", () => { + expect(readme).toMatch(/terraform init/); + expect(readme).toMatch(/terraform apply/); + expect(readme).toMatch(/docker-compose\.miner\.yml/); + expect(readme).toMatch(/\/data\/miner/); + }); +});