Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/gittensory-miner/terraform/README.md
Original file line number Diff line number Diff line change
@@ -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 |
102 changes: 102 additions & 0 deletions packages/gittensory-miner/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions packages/gittensory-miner/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -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"
}
39 changes: 39 additions & 0 deletions packages/gittensory-miner/terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -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"]
}
116 changes: 116 additions & 0 deletions test/unit/miner-terraform-module.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});