diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ef39312 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +**/dist +.git +**/.ponder +.env +**/.env +!**/.env.example +**/*.log +bootstrap/state.json +bootstrap/downloads diff --git a/.github/workflows/nightly-health.yml b/.github/workflows/nightly-health.yml new file mode 100644 index 0000000..0dba4d7 --- /dev/null +++ b/.github/workflows/nightly-health.yml @@ -0,0 +1,21 @@ +name: infra-nightly-health +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: +jobs: + health: + runs-on: ubuntu-latest + steps: + - name: Curl Sepolia MVP health endpoints + env: + INFRA_BASE_DOMAIN: ${{ secrets.INFRA_BASE_DOMAIN }} + run: | + if [ -z "$INFRA_BASE_DOMAIN" ]; then + echo "INFRA_BASE_DOMAIN secret absent; skipping" + exit 0 + fi + set -euo pipefail + curl -fsS --max-time 30 "https://seed1.${INFRA_BASE_DOMAIN}/v1/health" + curl -fsS --max-time 30 "https://registrar.${INFRA_BASE_DOMAIN}/v1/health" + curl -fsS --max-time 30 "https://index.${INFRA_BASE_DOMAIN}/v1/health" diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c7a4b..214637f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ details live in each package's `CHANGELOG.md`. ## 0.1.0 +WP-21 adds a one-box Sepolia MVP deploy under `infra/` (Caddy automatic HTTPS, +qBittorrent, Kubo, seed-api, registrar, indexer, bootstrap profile). See +`infra/README.md`. + WP-18 adds `enspack ens-setup` for one-shot Sepolia ENSv2 on-chain setup (issue #17). WP-17 wires Sepolia ENSv2 through CLI, bootstrap, seed, indexer, e2e and docs. Mainnet ENSv1 is unchanged. See `docs/ens-v2.md`. @@ -58,6 +62,11 @@ version-immutability violations, JSON API §4.3. Mirror runner for `bootstrap/models.yaml` under `mirrors.enspack.eth` (WP-12). `--chain sepolia` uses ENSv2 (4/2 txs + setup gas in `plan`). +### `@enspack/infra` + +WP-21 one-box Sepolia MVP: Caddy reverse proxy, qBittorrent, Kubo, seed-api, +registrar, indexer, bootstrap profile, `deploy.sh` / `bootstrap-remote.sh`. + ### `@enspack/e2e` Local Anvil mainnet-v1 swarm suite and Sepolia-fork ENSv2 suite in `pnpm check`. diff --git a/README.md b/README.md index cefa5f7..dbedd5c 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,18 @@ not published yet (MVP.md §0.1). Local proof uses Anvil + `test/fixtures/tiny-m | `services/registrar` | `@enspack/registrar` | HF-proof → `enspack.eth` publisher subnames | | `services/indexer` | `@enspack/indexer` | Ponder indexer + JSON API | | `bootstrap/` | `@enspack/bootstrap` | `models.yaml` mirror runner (`enspack-bootstrap plan|run`) | +| `infra/` | `@enspack/infra` | one-box Sepolia deploy (Caddy + compose + scripts) | | `test/fixtures` | — | tiny-model folder + torrent for swarm tests | | `test/e2e` | `@enspack/e2e` | local Anvil swarm (CI) + Sepolia (nightly) | Docs: `docs/publishing.md`, `docs/registrar.md`, `docs/seed-node.md`, `docs/indexer.md`, `docs/exit-codes.md`, `docs/ens-v2.md`. +## Deploy (Sepolia MVP) + +One Docker VM (Caddy + qBittorrent + Kubo + seed + registrar + indexer): +[`infra/README.md`](infra/README.md). + ## Development ```bash diff --git a/bootstrap/CHANGELOG.md b/bootstrap/CHANGELOG.md index e198044..9b6589f 100644 --- a/bootstrap/CHANGELOG.md +++ b/bootstrap/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.1.0 +- WP-21: `ENSPACK_BOOTSTRAP_STATE` overrides the `state.json` path so the one-box + seed volume can persist resume state. `bootstrap/Dockerfile` is the infra + compose `bootstrap` profile image (aria2c + `pnpm deploy --filter @enspack/bootstrap`). + - WP-17: `--chain sepolia` uses ENSv2 (`ensVersionFor`); `--ens-version` / `ENSPACK_ENS_VERSION` override. Plan gas totals include `PublishResultV2.setup`. README notes v2 tx counts (4/2) and that `mirrors.enspack.eth` needs a diff --git a/bootstrap/Dockerfile b/bootstrap/Dockerfile new file mode 100644 index 0000000..1d00dcf --- /dev/null +++ b/bootstrap/Dockerfile @@ -0,0 +1,22 @@ +# One-box seed image for `enspack-bootstrap` (infra compose profile `bootstrap`). +FROM node:22-alpine AS build +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /repo +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages ./packages +COPY schema ./schema +COPY bootstrap ./bootstrap +COPY infra/package.json ./infra/package.json +RUN mkdir -p test/e2e +RUN pnpm install --frozen-lockfile +RUN pnpm --filter @enspack/core --filter @enspack/hf --filter @enspack/torrent --filter @enspack/bootstrap build +RUN pnpm deploy --filter @enspack/bootstrap --prod /deploy + +FROM node:22-alpine +RUN apk add --no-cache aria2 +WORKDIR /app +COPY --from=build /deploy ./ +ENV NODE_ENV=production +USER node +ENTRYPOINT ["node", "dist/cli.js"] +CMD ["--help"] diff --git a/bootstrap/README.md b/bootstrap/README.md index 1c6f4a3..1674cb5 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -41,6 +41,7 @@ operator owns the name. Mainnet stays ENSv1 (3 / 2) and is still refused unless | `ENSPACK_KUBO_API` | `--pin kubo` (default `http://127.0.0.1:5001`) | | `ENSPACK_SEED_NODE` | `--pin seed` and `POST /v1/seed` | | `ENSPACK_BOOTSTRAP_ALLOW_MAINNET` | must be `1` **and** stdin must be a TTY to run mainnet | +| `ENSPACK_BOOTSTRAP_STATE` | path to `state.json` (default `/state.json`; infra volume uses `/data/state.json`) | | `ENSPACK_ENS_VERSION` | `v1` or `v2` (default v2 on sepolia, v1 on mainnet) | | `ENSPACK_ENSV2_*` | Universal Resolver / factory / implementation overrides | diff --git a/bootstrap/src/cli-args.ts b/bootstrap/src/cli-args.ts index b6fce0e..5904935 100644 --- a/bootstrap/src/cli-args.ts +++ b/bootstrap/src/cli-args.ts @@ -30,6 +30,7 @@ Usage: Env: SEPOLIA_RPC_URL / ETH_RPC_URL, ENSPACK_OPERATOR_KEY, HF_TOKEN, ENSPACK_KUBO_API, ENSPACK_SEED_NODE, ENSPACK_BOOTSTRAP_ALLOW_MAINNET, + ENSPACK_BOOTSTRAP_STATE (state.json path; default /state.json), ENSPACK_ENS_VERSION (v1|v2; default v2 on sepolia, v1 on mainnet), ENSPACK_ENSV2_* (Universal Resolver / factory / implementations) `; diff --git a/bootstrap/src/cli.ts b/bootstrap/src/cli.ts index 1ec2511..7244210 100644 --- a/bootstrap/src/cli.ts +++ b/bootstrap/src/cli.ts @@ -29,6 +29,15 @@ function packageRoot(): string { return fileURLToPath(new URL("..", import.meta.url)); } +/** BOOTSTRAP.md §5: `ENSPACK_BOOTSTRAP_STATE` so a seedbox volume can hold state.json. */ +export function bootstrapStatePath(env: NodeJS.ProcessEnv, pkgRoot: string): string { + const fromEnv = env.ENSPACK_BOOTSTRAP_STATE; + if (fromEnv !== undefined && fromEnv !== "") { + return fromEnv; + } + return join(pkgRoot, "state.json"); +} + function toJson(value: unknown): string { return `${JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2)}\n`; } @@ -97,7 +106,7 @@ export async function runCli(argv: string[], opts: RunCliOpts = {}): Promise { + it("defaults to /state.json", () => { + expect(bootstrapStatePath({}, "/app")).toBe(join("/app", "state.json")); + expect(bootstrapStatePath({ ENSPACK_BOOTSTRAP_STATE: "" }, "/app")).toBe( + join("/app", "state.json"), + ); + }); + + it("honors ENSPACK_BOOTSTRAP_STATE", () => { + expect(bootstrapStatePath({ ENSPACK_BOOTSTRAP_STATE: "/data/state.json" }, "/app")).toBe( + "/data/state.json", + ); + }); +}); diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..070bcd3 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,59 @@ +# Public DNS zone the three hosts live under (seed1 / registrar / index). +DOMAIN=example.com +# Let's Encrypt account email for Caddy automatic HTTPS. +ACME_EMAIL=admin@example.com + +# Sepolia JSON-RPC (required for the Sepolia MVP). Never commit a real URL with keys. +SEPOLIA_RPC_URL= +# Mainnet JSON-RPC. Leave empty for Sepolia-only. +ETH_RPC_URL= + +# Hot operator private key (0x + 64 hex). Registrar + bootstrap. Never commit a real value. +ENSPACK_OPERATOR_KEY= +# Chain the seed API resolves on. +ENSPACK_CHAIN=sepolia +# v1 or v2; default v2 on sepolia, v1 on mainnet. +ENSPACK_ENS_VERSION=v2 + +# Optional Universal Resolver override (empty = core Sepolia default). +ENSPACK_ENSV2_UNIVERSAL_RESOLVER= +# Optional VerifiableFactory override (empty = core Sepolia default). +ENSPACK_ENSV2_VERIFIABLE_FACTORY= +# Optional UserRegistry implementation override (empty = core Sepolia default). +ENSPACK_ENSV2_USER_REGISTRY_IMPL= +# Optional PermissionedResolver implementation override (empty = core Sepolia default). +ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL= +# Optional ETHRegistrar override (empty = core Sepolia default). +ENSPACK_ENSV2_ETH_REGISTRAR= + +# qBittorrent WebAPI login; must match the WebUI user/password set on first boot. +QBT_USER=admin +QBT_PASS= + +# Seed policy: publisher must equal a root or end with . (comma-separated). +SEED_ALLOW_ROOTS=enspack.eth +# Per-publisher seeded-bytes cap (2 TiB default). +SEED_QUOTA_BYTES_PER_PUBLISHER=2199023255552 +# Empty = @enspack/core LICENSE_ALLOWLIST. +SEED_LICENSE_ALLOWLIST= + +# Registrar chain (server.ts: REGISTRAR_CHAIN). Root name is core ROOT_NAME=enspack.eth, not an env var. +REGISTRAR_CHAIN=sepolia +# Empty = ensVersionFor(chain). Set v2 on Sepolia if you want to be explicit. +REGISTRAR_ENS_VERSION= +# Optional Postgres URL. Unset = PGlite on the registrar-data / indexer-data volumes. +DATABASE_URL= +# PGlite directory inside the registrar container (compose default /data/pglite). +PGLITE_DATA_DIR=/data/pglite + +# Optional Hugging Face read token (registrar claims + bootstrap downloads). +HF_TOKEN= + +# Extra Sepolia PermissionedResolver proxies (comma-separated), unioned with discovered names. +ENSPACK_INDEXER_RESOLVERS_SEPOLIA= +# Extra mainnet resolvers. Leave empty for Sepolia-only. +ENSPACK_INDEXER_RESOLVERS_MAINNET= +# First Sepolia block to index. Empty = indexer default 8000000. +ENSPACK_INDEXER_START_BLOCK_SEPOLIA= +# First mainnet block. Empty = indexer default 23000000. +ENSPACK_INDEXER_START_BLOCK_MAINNET= diff --git a/infra/CHANGELOG.md b/infra/CHANGELOG.md new file mode 100644 index 0000000..0adb8a5 --- /dev/null +++ b/infra/CHANGELOG.md @@ -0,0 +1,7 @@ +# @enspack/infra + +## 0.1.0 + +- WP-21: one-box Sepolia MVP compose stack under `infra/` (Caddy, qBittorrent, + Kubo, seed-api, registrar, indexer, bootstrap profile), deploy/bootstrap-remote + scripts, nightly health workflow, and structure tests (no Docker daemon). diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..db4805a --- /dev/null +++ b/infra/Caddyfile @@ -0,0 +1,15 @@ +{ + email {$ACME_EMAIL} +} + +seed1.{$DOMAIN} { + reverse_proxy seed-api:8080 +} + +registrar.{$DOMAIN} { + reverse_proxy registrar:8080 +} + +index.{$DOMAIN} { + reverse_proxy indexer:42069 +} diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..875328d --- /dev/null +++ b/infra/README.md @@ -0,0 +1,115 @@ +# One-box Sepolia MVP + +One Ubuntu VM running the seed API, registrar, indexer, qBittorrent, Kubo, and +Caddy with automatic HTTPS. Matches BOOTSTRAP.md §4 (one seedbox) and MVP.md §4 +HTTP contracts. + +## What the box needs + +- Ubuntu 24.04 +- Docker Engine + the Compose plugin (`docker compose version`) +- Open ports: **80**, **443**, **6881/tcp+udp**, **4001/tcp+udp** +- DNS **A** records for: + - `seed1.$DOMAIN` → seed API (`/v1/seed`, `/v1/pin`, `/v1/health`) + - `registrar.$DOMAIN` → registrar (`/v1/claims`, `/v1/health`) + - `index.$DOMAIN` → indexer (`/v1/names`, `/v1/health`; Ponder also serves `/health` and `/ready`) + +The operator key (`ENSPACK_OPERATOR_KEY`) must already be granted ENSv2 registrar +roles on `enspack.eth` — see `docs/ens-v2.md`. Root name is core `ROOT_NAME` +(`enspack.eth`); there is no `REGISTRAR_ROOT_NAME` env. + +## First boot + +On a laptop (or a Cloud Agent VM) that can SSH to the box: + +```bash +cp infra/.env.example infra/.env +# fill DOMAIN, ACME_EMAIL, SEPOLIA_RPC_URL, ENSPACK_OPERATOR_KEY, QBT_USER, QBT_PASS +# optional: HF_TOKEN, DATABASE_URL, ENSPACK_ENSV2_* + +# first login to qBittorrent WebUI is internal-only. After compose is up, from +# the box: docker compose -f infra/docker-compose.yml exec qbittorrent ... +# Set the WebUI password to match QBT_PASS (QBT_USER defaults to admin). + +chmod +x infra/deploy.sh infra/bootstrap-remote.sh +export SSH_KEY_FILE=/path/to/seedbox.pem # optional; honoured by both scripts +./infra/deploy.sh ubuntu@your.seedbox.host +``` + +`deploy.sh` rsyncs the repo (excluding `node_modules`, `dist`, `.git`) to +`/opt/enspack`, copies `infra/.env` if present, runs +`docker compose -f infra/docker-compose.yml up -d --build`, waits for the three +HTTPS health endpoints, and prints them. SSH uses `-o BatchMode=yes`. + +Then seed Tier 1 (first 3 by default): + +```bash +./infra/bootstrap-remote.sh ubuntu@your.seedbox.host --tier 1 --limit 3 +``` + +That runs `enspack-bootstrap plan` then `run` in a one-off container (`bootstrap` +compose profile) on the same `.env`, with downloads on the shared `downloads` +volume. + +## How to check health + +```bash +curl -fsS "https://seed1.$DOMAIN/v1/health" +curl -fsS "https://registrar.$DOMAIN/v1/health" +curl -fsS "https://index.$DOMAIN/v1/health" +``` + +On the box, without publishing app ports: + +```bash +cd /opt/enspack +docker compose -f infra/docker-compose.yml ps +``` + +qBittorrent WebUI (`8081`) and Kubo API (`5001`) are **not** on the host. + +`.github/workflows/nightly-health.yml` (source: `infra/nightly-health.yml`) curls +the three endpoints on a cron when the `INFRA_BASE_DOMAIN` secret is set, and +skips when it is absent. + +## Rotate the operator key + +1. Generate a new key. Grant it ENSv2 `ROLE_REGISTRAR | ROLE_RENEW` on the + `enspack.eth` UserRegistry and resolver text roles (`docs/ens-v2.md`). +2. Replace `ENSPACK_OPERATOR_KEY` in `infra/.env` on the box (or locally, then + re-run `deploy.sh`). Never echo the key. +3. `docker compose -f infra/docker-compose.yml up -d registrar` (and the next + `bootstrap-remote.sh`). +4. Revoke the old address's roles. Confirm `GET https://registrar.$DOMAIN/v1/health` + shows `approved: true` for the new operator. + +## Where downloads live + +Torrent payloads and bootstrap snapshots are the Docker volume `enspack_downloads`, +mounted at `/downloads` in `qbittorrent`, `seed-api`, and `bootstrap`. +qBittorrent `savepath` is `SEED_DOWNLOAD_DIR=/downloads`. + +## Backup PGlite volumes + +Default (no `DATABASE_URL`): registrar PGlite is `enspack_registrar-data` +(`/data/pglite`); indexer PGlite is `enspack_indexer-data` +(`/app/services/indexer/.ponder`). Bootstrap resume state is +`enspack_bootstrap-data` (`/data/state.json`). + +```bash +docker run --rm \ + -v enspack_registrar-data:/data \ + -v "$PWD":/backup \ + alpine tar czf /backup/registrar-pglite.tgz -C /data . + +docker run --rm \ + -v enspack_indexer-data:/data \ + -v "$PWD":/backup \ + alpine tar czf /backup/indexer-ponder.tgz -C /data . +``` + +Restore by extracting into the same volumes before `compose up`. If +`DATABASE_URL` is set, back up that Postgres instead. + +Caddy ACME certs: `enspack_caddy-data`. Kubo repo: `enspack_kubo-data`. +qBittorrent config: `enspack_qbt-config`. diff --git a/infra/bootstrap-remote.sh b/infra/bootstrap-remote.sh new file mode 100755 index 0000000..ae16faa --- /dev/null +++ b/infra/bootstrap-remote.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Run enspack-bootstrap plan then run on the box via the bootstrap compose profile. +set -euo pipefail + +usage() { + printf 'usage: %s [--tier 1] [--limit 3]\n' "${0##*/}" >&2 +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage + exit 0 +fi +if [[ "$1" == -* ]]; then + usage + exit 1 +fi + +HOST="$1" +shift +TIER=1 +LIMIT=3 +while [[ $# -gt 0 ]]; do + case "$1" in + --tier) + TIER="${2:?--tier requires a value}" + shift 2 + ;; + --limit) + LIMIT="${2:?--limit requires a value}" + shift 2 + ;; + *) + printf 'unexpected argument: %s\n' "$1" >&2 + usage + exit 1 + ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REMOTE="${ENSPACK_REMOTE_ROOT:-/opt/enspack}" + +ssh_cmd=(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new) +if [[ -n "${SSH_KEY_FILE:-}" ]]; then + ssh_cmd+=(-i "$SSH_KEY_FILE") +fi + +rsync -az --delete \ + --exclude node_modules \ + --exclude dist \ + --exclude .git \ + --exclude .ponder \ + --exclude .env \ + --exclude '*.log' \ + -e "${ssh_cmd[*]}" \ + "$ROOT/" \ + "$HOST:$REMOTE/" + +if [[ -f "$ROOT/infra/.env" ]]; then + rsync -az -e "${ssh_cmd[*]}" "$ROOT/infra/.env" "$HOST:$REMOTE/infra/.env" +fi + +compose=(docker compose -f infra/docker-compose.yml --profile bootstrap) +remote_q=$(printf '%q' "$REMOTE") +tier_q=$(printf '%q' "$TIER") +limit_q=$(printf '%q' "$LIMIT") + +"${ssh_cmd[@]}" "$HOST" "cd ${remote_q} && ${compose[*]} run --rm bootstrap plan --tier ${tier_q} --limit ${limit_q} --chain sepolia --json" +"${ssh_cmd[@]}" "$HOST" "cd ${remote_q} && ${compose[*]} run --rm bootstrap run --tier ${tier_q} --limit ${limit_q} --chain sepolia --pin seed --seed-node http://seed-api:8080 --downloads /downloads --resume" diff --git a/infra/deploy.sh b/infra/deploy.sh new file mode 100755 index 0000000..321e947 --- /dev/null +++ b/infra/deploy.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Idempotent one-box deploy: rsync this repo to /opt/enspack and compose up. +set -euo pipefail + +usage() { + printf 'usage: %s \n' "${0##*/}" >&2 +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + usage + exit 0 +fi +if [[ "$1" == -* ]]; then + usage + exit 1 +fi + +HOST="$1" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +REMOTE="${ENSPACK_REMOTE_ROOT:-/opt/enspack}" + +ssh_cmd=(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new) +if [[ -n "${SSH_KEY_FILE:-}" ]]; then + ssh_cmd+=(-i "$SSH_KEY_FILE") +fi + +rsync -az --delete \ + --exclude node_modules \ + --exclude dist \ + --exclude .git \ + --exclude .ponder \ + --exclude .env \ + --exclude '*.log' \ + -e "${ssh_cmd[*]}" \ + "$ROOT/" \ + "$HOST:$REMOTE/" + +if [[ -f "$ROOT/infra/.env" ]]; then + rsync -az -e "${ssh_cmd[*]}" "$ROOT/infra/.env" "$HOST:$REMOTE/infra/.env" +fi + +"${ssh_cmd[@]}" "$HOST" "cd $(printf '%q' "$REMOTE") && docker compose -f infra/docker-compose.yml up -d --build" + +DOMAIN="" +if [[ -f "$ROOT/infra/.env" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + DOMAIN=*) + DOMAIN="${line#DOMAIN=}" + DOMAIN="${DOMAIN%\"}" + DOMAIN="${DOMAIN#\"}" + DOMAIN="${DOMAIN%\'}" + DOMAIN="${DOMAIN#\'}" + ;; + esac + done < "$ROOT/infra/.env" +fi +if [[ -z "$DOMAIN" ]]; then + printf 'DOMAIN is not set in infra/.env; cannot wait for HTTPS health\n' >&2 + exit 1 +fi + +seed_url="https://seed1.${DOMAIN}/v1/health" +registrar_url="https://registrar.${DOMAIN}/v1/health" +index_url="https://index.${DOMAIN}/v1/health" + +wait_url() { + local url="$1" + local i + for i in $(seq 1 60); do + if curl -fsS --max-time 10 "$url" >/dev/null; then + return 0 + fi + sleep 5 + done + printf 'timed out waiting for %s\n' "$url" >&2 + exit 1 +} + +wait_url "$seed_url" +wait_url "$registrar_url" +wait_url "$index_url" + +printf '%s\n' "$seed_url" "$registrar_url" "$index_url" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..86093f2 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,246 @@ +# WP-21: one-box Sepolia MVP (Caddy + qBittorrent + Kubo + seed + registrar + indexer). +# Secrets come from infra/.env — never from this file. +# +# Resource notes (BOOTSTRAP.md §4): +# - Disk: 4 TB for Tier 1+2 snapshots on the `downloads` volume (~220 GB Tier 1). +# - Network: unmetered 1 Gbit; publish 80/443 (Caddy), 6881 tcp/udp (BitTorrent), +# 4001 tcp/udp (Kubo swarm). Kubo API, qBittorrent WebUI, registrar and indexer +# stay on the compose network only. +# - CPU/RAM: 4 vCPU / 16 GB is enough for the three Node services + qBittorrent + Kubo. +# - PGlite volumes (`registrar-data`, `indexer-data`) are the default DB; set +# DATABASE_URL to use Postgres instead. + +name: enspack + +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + DOMAIN: ${DOMAIN} + ACME_EMAIL: ${ACME_EMAIL} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + seed-api: + condition: service_healthy + registrar: + condition: service_healthy + indexer: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:2019/config/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent + restart: unless-stopped + environment: + PUID: "1000" + PGID: "1000" + TZ: Etc/UTC + WEBUI_PORT: "8081" + TORRENTING_PORT: "6881" + ports: + - "6881:6881" + - "6881:6881/udp" + volumes: + - qbt-config:/config + - downloads:/downloads + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081 >/dev/null"] + interval: 30s + timeout: 5s + retries: 10 + start_period: 40s + + kubo: + image: ipfs/kubo + restart: unless-stopped + environment: + IPFS_PROFILE: server + ports: + - "4001:4001" + - "4001:4001/udp" + volumes: + - kubo-staging:/export + - kubo-data:/data/ipfs + healthcheck: + test: ["CMD", "ipfs", "id"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + + seed-api: + build: + context: .. + dockerfile: services/seed/Dockerfile + restart: unless-stopped + environment: + PORT: "8080" + QBT_URL: http://qbittorrent:8081 + QBT_USER: ${QBT_USER} + QBT_PASS: ${QBT_PASS} + KUBO_API: http://kubo:5001 + ETH_RPC_URL: ${ETH_RPC_URL:-} + SEPOLIA_RPC_URL: ${SEPOLIA_RPC_URL} + ENSPACK_CHAIN: ${ENSPACK_CHAIN:-sepolia} + ENSPACK_ENS_VERSION: ${ENSPACK_ENS_VERSION:-v2} + ENSPACK_ENSV2_UNIVERSAL_RESOLVER: ${ENSPACK_ENSV2_UNIVERSAL_RESOLVER:-} + ENSPACK_ENSV2_VERIFIABLE_FACTORY: ${ENSPACK_ENSV2_VERIFIABLE_FACTORY:-} + ENSPACK_ENSV2_USER_REGISTRY_IMPL: ${ENSPACK_ENSV2_USER_REGISTRY_IMPL:-} + ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL: ${ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL:-} + ENSPACK_ENSV2_ETH_REGISTRAR: ${ENSPACK_ENSV2_ETH_REGISTRAR:-} + SEED_ALLOW_ROOTS: ${SEED_ALLOW_ROOTS:-enspack.eth} + SEED_QUOTA_BYTES_PER_PUBLISHER: ${SEED_QUOTA_BYTES_PER_PUBLISHER:-2199023255552} + SEED_LICENSE_ALLOWLIST: ${SEED_LICENSE_ALLOWLIST:-} + SEED_DOWNLOAD_DIR: /downloads + HF_TOKEN: ${HF_TOKEN:-} + volumes: + - downloads:/downloads + depends_on: + qbittorrent: + condition: service_healthy + kubo: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:8080/v1/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + registrar: + build: + context: .. + dockerfile: services/registrar/Dockerfile + restart: unless-stopped + environment: + PORT: "8080" + ENSPACK_OPERATOR_KEY: ${ENSPACK_OPERATOR_KEY} + REGISTRAR_CHAIN: ${REGISTRAR_CHAIN:-sepolia} + REGISTRAR_ENS_VERSION: ${REGISTRAR_ENS_VERSION:-} + SEPOLIA_RPC_URL: ${SEPOLIA_RPC_URL} + ETH_RPC_URL: ${ETH_RPC_URL:-} + DATABASE_URL: ${DATABASE_URL:-} + PGLITE_DATA_DIR: ${PGLITE_DATA_DIR:-/data/pglite} + HF_TOKEN: ${HF_TOKEN:-} + ENSPACK_ENS_VERSION: ${ENSPACK_ENS_VERSION:-v2} + ENSPACK_ENSV2_UNIVERSAL_RESOLVER: ${ENSPACK_ENSV2_UNIVERSAL_RESOLVER:-} + ENSPACK_ENSV2_VERIFIABLE_FACTORY: ${ENSPACK_ENSV2_VERIFIABLE_FACTORY:-} + ENSPACK_ENSV2_USER_REGISTRY_IMPL: ${ENSPACK_ENSV2_USER_REGISTRY_IMPL:-} + ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL: ${ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL:-} + ENSPACK_ENSV2_ETH_REGISTRAR: ${ENSPACK_ENSV2_ETH_REGISTRAR:-} + volumes: + - registrar-data:/data/pglite + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:8080/v1/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + indexer: + build: + context: .. + dockerfile: services/indexer/Dockerfile + restart: unless-stopped + environment: + ETH_RPC_URL: ${ETH_RPC_URL:-} + SEPOLIA_RPC_URL: ${SEPOLIA_RPC_URL} + DATABASE_URL: ${DATABASE_URL:-} + ENSPACK_ENS_VERSION: ${ENSPACK_ENS_VERSION:-v2} + ENSPACK_ENSV2_UNIVERSAL_RESOLVER: ${ENSPACK_ENSV2_UNIVERSAL_RESOLVER:-} + ENSPACK_ENSV2_VERIFIABLE_FACTORY: ${ENSPACK_ENSV2_VERIFIABLE_FACTORY:-} + ENSPACK_ENSV2_USER_REGISTRY_IMPL: ${ENSPACK_ENSV2_USER_REGISTRY_IMPL:-} + ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL: ${ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL:-} + ENSPACK_ENSV2_ETH_REGISTRAR: ${ENSPACK_ENSV2_ETH_REGISTRAR:-} + ENSPACK_INDEXER_RESOLVERS_SEPOLIA: ${ENSPACK_INDEXER_RESOLVERS_SEPOLIA:-} + ENSPACK_INDEXER_RESOLVERS_MAINNET: ${ENSPACK_INDEXER_RESOLVERS_MAINNET:-} + ENSPACK_INDEXER_START_BLOCK_SEPOLIA: ${ENSPACK_INDEXER_START_BLOCK_SEPOLIA:-} + ENSPACK_INDEXER_START_BLOCK_MAINNET: ${ENSPACK_INDEXER_START_BLOCK_MAINNET:-} + volumes: + - indexer-data:/app/services/indexer/.ponder + healthcheck: + # Ponder also serves /health and /ready on :42069; /v1/health is the §4.3 contract. + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:42069/v1/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + + bootstrap: + profiles: ["bootstrap"] + build: + context: .. + dockerfile: bootstrap/Dockerfile + restart: "no" + environment: + SEPOLIA_RPC_URL: ${SEPOLIA_RPC_URL} + ETH_RPC_URL: ${ETH_RPC_URL:-} + ENSPACK_OPERATOR_KEY: ${ENSPACK_OPERATOR_KEY} + ENSPACK_CHAIN: ${ENSPACK_CHAIN:-sepolia} + ENSPACK_ENS_VERSION: ${ENSPACK_ENS_VERSION:-v2} + ENSPACK_ENSV2_UNIVERSAL_RESOLVER: ${ENSPACK_ENSV2_UNIVERSAL_RESOLVER:-} + ENSPACK_ENSV2_VERIFIABLE_FACTORY: ${ENSPACK_ENSV2_VERIFIABLE_FACTORY:-} + ENSPACK_ENSV2_USER_REGISTRY_IMPL: ${ENSPACK_ENSV2_USER_REGISTRY_IMPL:-} + ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL: ${ENSPACK_ENSV2_PERMISSIONED_RESOLVER_IMPL:-} + ENSPACK_ENSV2_ETH_REGISTRAR: ${ENSPACK_ENSV2_ETH_REGISTRAR:-} + HF_TOKEN: ${HF_TOKEN:-} + ENSPACK_SEED_NODE: http://seed-api:8080 + ENSPACK_KUBO_API: http://kubo:5001 + ENSPACK_BOOTSTRAP_STATE: /data/state.json + volumes: + - downloads:/downloads + - bootstrap-data:/data + depends_on: + seed-api: + condition: service_healthy + kubo: + condition: service_healthy + entrypoint: ["node", "dist/cli.js"] + command: ["--help"] + healthcheck: + test: ["CMD", "node", "dist/cli.js", "--help"] + interval: 30s + timeout: 10s + retries: 1 + start_period: 5s + +volumes: + caddy-data: + caddy-config: + qbt-config: + downloads: + kubo-staging: + kubo-data: + registrar-data: + indexer-data: + bootstrap-data: diff --git a/infra/nightly-health.yml b/infra/nightly-health.yml new file mode 100644 index 0000000..0dba4d7 --- /dev/null +++ b/infra/nightly-health.yml @@ -0,0 +1,21 @@ +name: infra-nightly-health +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: +jobs: + health: + runs-on: ubuntu-latest + steps: + - name: Curl Sepolia MVP health endpoints + env: + INFRA_BASE_DOMAIN: ${{ secrets.INFRA_BASE_DOMAIN }} + run: | + if [ -z "$INFRA_BASE_DOMAIN" ]; then + echo "INFRA_BASE_DOMAIN secret absent; skipping" + exit 0 + fi + set -euo pipefail + curl -fsS --max-time 30 "https://seed1.${INFRA_BASE_DOMAIN}/v1/health" + curl -fsS --max-time 30 "https://registrar.${INFRA_BASE_DOMAIN}/v1/health" + curl -fsS --max-time 30 "https://index.${INFRA_BASE_DOMAIN}/v1/health" diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..f9d9b1f --- /dev/null +++ b/infra/package.json @@ -0,0 +1,21 @@ +{ + "name": "@enspack/infra", + "version": "0.1.0", + "private": true, + "description": "One-box Sepolia MVP compose stack, deploy scripts, and structure tests (WP-21)", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "check": "pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "yaml": "^2.7.0" + } +} diff --git a/infra/test/compose.test.ts b/infra/test/compose.test.ts new file mode 100644 index 0000000..31546f3 --- /dev/null +++ b/infra/test/compose.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { + type ComposeService, + INTERNAL_ONLY_HOST_PORTS, + KEY_RE, + TOKEN_RE, + envExampleKeys, + interpolateCompose, + interpolateVars, + parseEnvExample, + publishedHostPorts, + readInfra, +} from "./helpers.js"; + +const SEVEN = [ + "bootstrap", + "caddy", + "indexer", + "kubo", + "qbittorrent", + "registrar", + "seed-api", +] as const; + +const INTERNAL_ONLY = new Set(INTERNAL_ONLY_HOST_PORTS); + +describe("infra docker-compose.yml", () => { + const raw = readInfra("docker-compose.yml"); + const envExample = readInfra(".env.example"); + const env = parseEnvExample(envExample); + const interpolated = interpolateCompose(raw, env); + const doc = parse(interpolated) as { services?: Record }; + + it("parses and has exactly the seven services", () => { + expect(doc.services).toBeDefined(); + const names = Object.keys(doc.services ?? {}).sort(); + expect(names).toEqual([...SEVEN]); + }); + + it("publishes only caddy 80/443, qbittorrent 6881, kubo 4001", () => { + const services = doc.services ?? {}; + expect([...new Set(publishedHostPorts(services.caddy))].sort((a, b) => a - b)).toEqual([ + 80, 443, + ]); + expect([...new Set(publishedHostPorts(services.qbittorrent))]).toEqual([6881]); + expect([...new Set(publishedHostPorts(services.kubo))]).toEqual([4001]); + expect(publishedHostPorts(services["seed-api"])).toEqual([]); + expect(publishedHostPorts(services.registrar)).toEqual([]); + expect(publishedHostPorts(services.indexer)).toEqual([]); + expect(publishedHostPorts(services.bootstrap)).toEqual([]); + + for (const [name, svc] of Object.entries(services)) { + for (const port of publishedHostPorts(svc)) { + expect(INTERNAL_ONLY.has(port), `${name} published internal port ${port}`).toBe(false); + } + } + }); + + it("every service has restart and a healthcheck", () => { + for (const name of SEVEN) { + const svc = doc.services?.[name]; + expect(svc, name).toBeDefined(); + expect(svc?.restart, `${name} restart`).not.toBeUndefined(); + expect(svc?.healthcheck, `${name} healthcheck`).toBeDefined(); + expect(svc?.healthcheck?.test, `${name} healthcheck.test`).toBeDefined(); + } + }); + + it("bootstrap is a compose profile (not started by up -d)", () => { + const profiles = doc.services?.bootstrap?.profiles; + expect(profiles).toEqual(["bootstrap"]); + }); + + it("all ${VAR} references appear in .env.example", () => { + const keys = envExampleKeys(envExample); + for (const name of interpolateVars(raw)) { + expect(keys.has(name), `${name} missing from .env.example`).toBe(true); + } + }); + + it("seed-api and registrar healthcheck /v1/health; indexer uses Ponder /v1/health", () => { + const dump = JSON.stringify(doc.services); + expect(dump).toContain("/v1/health"); + expect(JSON.stringify(doc.services?.["seed-api"]?.healthcheck)).toContain("/v1/health"); + expect(JSON.stringify(doc.services?.registrar?.healthcheck)).toContain("/v1/health"); + expect(JSON.stringify(doc.services?.indexer?.healthcheck)).toMatch( + /\/v1\/health|\/ready|\/health/, + ); + }); +}); + +describe("Caddyfile", () => { + it("proxies the three hosts from DOMAIN env", () => { + const raw = readInfra("Caddyfile"); + expect(raw).toContain("seed1.{$DOMAIN}"); + expect(raw).toContain("registrar.{$DOMAIN}"); + expect(raw).toContain("index.{$DOMAIN}"); + expect(raw).toContain("reverse_proxy seed-api:8080"); + expect(raw).toContain("reverse_proxy registrar:8080"); + expect(raw).toContain("reverse_proxy indexer:42069"); + expect(raw).toContain("email {$ACME_EMAIL}"); + }); +}); + +describe("no baked secrets", () => { + it("compose and Caddyfile contain no 0x+64-hex key or token", () => { + const compose = readInfra("docker-compose.yml"); + const caddy = readInfra("Caddyfile"); + expect(compose).not.toMatch(KEY_RE); + expect(caddy).not.toMatch(KEY_RE); + expect(compose).not.toMatch(TOKEN_RE); + expect(caddy).not.toMatch(TOKEN_RE); + }); +}); diff --git a/infra/test/helpers.ts b/infra/test/helpers.ts new file mode 100644 index 0000000..2df21af --- /dev/null +++ b/infra/test/helpers.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const infraRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Read a file under `infra/` as utf8. */ +export function readInfra(rel: string): string { + return readFileSync(join(infraRoot, rel), "utf8"); +} + +/** Compose `${VAR}` / `${VAR:-default}` names in document order. */ +export function interpolateVars(raw: string): string[] { + const names = new Set(); + for (const m of raw.matchAll(/\$\{([A-Z][A-Z0-9_]*)/g)) { + const name = m[1]; + if (name !== undefined) names.add(name); + } + return [...names].sort(); +} + +/** KEY= lines from .env.example, ignoring comments. */ +export function envExampleKeys(raw: string): Set { + const keys = new Set(); + for (const line of raw.split("\n")) { + const m = /^([A-Z][A-Z0-9_]*)=/.exec(line); + if (m?.[1] !== undefined) keys.add(m[1]); + } + return keys; +} + +/** Parse `.env.example` into a map (empty values stay empty strings). */ +export function parseEnvExample(raw: string): Record { + const env: Record = {}; + for (const line of raw.split("\n")) { + const m = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line); + if (m?.[1] === undefined) continue; + env[m[1]] = m[2] ?? ""; + } + return env; +} + +/** Substitute `${VAR}` and `${VAR:-default}` the way Compose does for `config`. */ +export function interpolateCompose(raw: string, env: Record): string { + return raw.replace( + /\$\{([A-Z][A-Z0-9_]*)(:-([^}]*))?\}/g, + (_all, name: string, _d, def?: string) => { + const value = env[name]; + if (value !== undefined && value !== "") return value; + return def ?? ""; + }, + ); +} + +export type ComposeService = { + restart?: unknown; + healthcheck?: { test?: unknown }; + ports?: unknown; + profiles?: unknown; + environment?: Record; +}; + +/** Host ports published by a compose service (short or long syntax). */ +export function publishedHostPorts(service: unknown): number[] { + if (typeof service !== "object" || service === null) return []; + const ports = (service as { ports?: unknown }).ports; + if (!Array.isArray(ports)) return []; + const out: number[] = []; + for (const p of ports) { + if (typeof p === "string") { + const noProto = p.replace(/\/(tcp|udp)$/i, ""); + const parts = noProto.split(":"); + if (parts.length === 2 && parts[0] !== undefined) { + out.push(Number(parts[0])); + } else if (parts.length === 3 && parts[1] !== undefined) { + out.push(Number(parts[1])); + } + } else if (typeof p === "object" && p !== null && "published" in p) { + const published = (p as { published?: unknown }).published; + if (typeof published === "number" || typeof published === "string") { + out.push(Number(published)); + } + } + } + return out.filter((n) => Number.isInteger(n)); +} + +/** Host ports that must stay on the compose network (API / WebUI / app HTTP). */ +export const INTERNAL_ONLY_HOST_PORTS = [5001, 8080, 8081, 8787, 42069] as const; + +/** Private-key shape that must never appear in compose or Caddyfile. */ +export const KEY_RE = /0x[0-9a-fA-F]{64}/; +/** Hugging Face / GitHub / OpenAI token prefixes that must never be baked in. */ +export const TOKEN_RE = /\b(?:ghp_|github_pat_|hf_|sk-)[A-Za-z0-9]+/; diff --git a/infra/test/scripts.test.ts b/infra/test/scripts.test.ts new file mode 100644 index 0000000..137eb0a --- /dev/null +++ b/infra/test/scripts.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { infraRoot, readInfra } from "./helpers.js"; + +const LEAK_RE = + /echo\s+["']?\$\{?(ENSPACK_OPERATOR_KEY|QBT_PASS|HF_TOKEN|SEPOLIA_RPC_URL|ETH_RPC_URL|ACME_EMAIL)/; + +describe("deploy.sh and bootstrap-remote.sh", () => { + const deploy = join(infraRoot, "deploy.sh"); + const bootstrap = join(infraRoot, "bootstrap-remote.sh"); + + it("bash -n passes on both scripts", () => { + for (const script of [deploy, bootstrap]) { + const r = spawnSync("bash", ["-n", script], { encoding: "utf8" }); + expect(r.status, `${script}: ${r.stderr}`).toBe(0); + } + }); + + it("deploy.sh refuses to run without a host arg", () => { + const r = spawnSync("bash", [deploy], { encoding: "utf8" }); + expect(r.status).not.toBe(0); + expect(`${r.stdout}${r.stderr}`).toMatch(/usage:/); + }); + + it("bootstrap-remote.sh refuses to run without a host arg", () => { + const r = spawnSync("bash", [bootstrap], { encoding: "utf8" }); + expect(r.status).not.toBe(0); + expect(`${r.stdout}${r.stderr}`).toMatch(/usage:/); + }); + + it("never echoes operator key / RPC / tokens (grep)", () => { + const deploySrc = readInfra("deploy.sh"); + const bootSrc = readInfra("bootstrap-remote.sh"); + expect(deploySrc).not.toMatch(LEAK_RE); + expect(bootSrc).not.toMatch(LEAK_RE); + expect(deploySrc).not.toMatch(/echo\s+\$ENSPACK_OPERATOR_KEY/); + expect(bootSrc).not.toMatch(/echo\s+\$ENSPACK_OPERATOR_KEY/); + expect(deploySrc).not.toContain("set -x"); + expect(bootSrc).not.toContain("set -x"); + expect(deploySrc).toContain("BatchMode=yes"); + expect(bootSrc).toContain("BatchMode=yes"); + expect(deploySrc).toContain("SSH_KEY_FILE"); + expect(bootSrc).toContain("SSH_KEY_FILE"); + }); +}); diff --git a/infra/test/workflow.test.ts b/infra/test/workflow.test.ts new file mode 100644 index 0000000..ebe96a9 --- /dev/null +++ b/infra/test/workflow.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { readInfra } from "./helpers.js"; + +describe("infra/nightly-health.yml", () => { + it("parses as a GitHub workflow that skips without INFRA_BASE_DOMAIN", () => { + const raw = readInfra("nightly-health.yml"); + const doc = parse(raw) as { + on?: { schedule?: unknown }; + jobs?: Record; run?: string }> }>; + }; + expect(doc.on?.schedule).toBeDefined(); + expect(raw).toContain("secrets.INFRA_BASE_DOMAIN"); + const health = doc.jobs?.health; + expect(health).toBeDefined(); + const run = health?.steps?.map((s) => s.run ?? "").join("\n") ?? ""; + expect(run).toMatch(/INFRA_BASE_DOMAIN secret absent; skipping/); + expect(run).toContain("seed1.${INFRA_BASE_DOMAIN}/v1/health"); + expect(run).toContain("registrar.${INFRA_BASE_DOMAIN}/v1/health"); + expect(run).toContain("index.${INFRA_BASE_DOMAIN}/v1/health"); + }); +}); diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..518ba5b --- /dev/null +++ b/infra/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["test/**/*.ts", "vitest.config.ts"] +} diff --git a/infra/vitest.config.ts b/infra/vitest.config.ts new file mode 100644 index 0000000..ed8bf77 --- /dev/null +++ b/infra/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96aa165..131428d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,21 @@ importers: specifier: ^3.0.0 version: 3.2.7(@types/node@22.20.2)(yaml@2.9.1) + infra: + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.2)(yaml@2.9.1) + yaml: + specifier: ^2.7.0 + version: 2.9.1 + packages/cli: dependencies: '@enspack/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5c68500..a2b26bb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,5 @@ packages: - "packages/*" - "services/*" - "bootstrap" + - "infra" - "test/e2e" diff --git a/services/indexer/CHANGELOG.md b/services/indexer/CHANGELOG.md index 36f3645..77916ad 100644 --- a/services/indexer/CHANGELOG.md +++ b/services/indexer/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.0 +- WP-21: production Dockerfile copies `infra/package.json` so the workspace + stays complete when `@enspack/infra` is present. - WP-17: Sepolia (v2) discovers per-publisher PermissionedResolver proxies via `findResolverV2(enspack.eth)` and `findResolverV2(mirrors.enspack.eth)`, unions `ENSPACK_INDEXER_RESOLVERS_SEPOLIA`. Mainnet stays v1 `readResolverAddress`. diff --git a/services/indexer/Dockerfile b/services/indexer/Dockerfile index 493eedf..84e55c2 100644 --- a/services/indexer/Dockerfile +++ b/services/indexer/Dockerfile @@ -8,6 +8,7 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./ COPY schema ./schema COPY packages/core ./packages/core COPY services/indexer ./services/indexer +COPY infra/package.json ./infra/package.json RUN pnpm install --frozen-lockfile --filter @enspack/indexer... RUN pnpm --filter @enspack/core build diff --git a/services/registrar/CHANGELOG.md b/services/registrar/CHANGELOG.md index b367367..9e02b58 100644 --- a/services/registrar/CHANGELOG.md +++ b/services/registrar/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.1 +- WP-21: production Dockerfile copies `infra/package.json` so the workspace + stays complete when `@enspack/infra` is present. - WP-16: ENSv2 mode behind `REGISTRAR_ENS_VERSION` / core `ensVersionFor` (SPEC §7 as amended by [issue #17](https://github.com/jefflau/enspack/issues/17)). v2 issuance is 2 transactions (`register` + resolver `multicall`). v1 path and Anvil mainnet-fork test unchanged. ## 0.1.0 diff --git a/services/registrar/Dockerfile b/services/registrar/Dockerfile index fccc725..4e3c40e 100644 --- a/services/registrar/Dockerfile +++ b/services/registrar/Dockerfile @@ -9,6 +9,7 @@ COPY schema ./schema COPY packages/core ./packages/core COPY packages/hf ./packages/hf COPY services/registrar ./services/registrar +COPY infra/package.json ./infra/package.json RUN pnpm install --frozen-lockfile --filter @enspack/registrar... RUN pnpm --filter @enspack/core build diff --git a/services/seed/CHANGELOG.md b/services/seed/CHANGELOG.md index 124e4d9..3502f05 100644 --- a/services/seed/CHANGELOG.md +++ b/services/seed/CHANGELOG.md @@ -4,4 +4,6 @@ - WP-17: resolver is constructed with the chain's `ensVersion` / `ensV2` (`ENSPACK_ENS_VERSION`, `ENSPACK_ENSV2_*`). `/v1/health` reports `ensVersion`. +- WP-21: production Dockerfile copies `infra/package.json` so the workspace + stays complete when `@enspack/infra` is present. - Initial seed node (WP-09): Hono API per MVP.md §4.2, qBittorrent + Kubo compose stack, policy (`allowRoots`, license, per-publisher quota), and in-process acceptance tests. diff --git a/services/seed/Dockerfile b/services/seed/Dockerfile index 2719c50..a2ab140 100644 --- a/services/seed/Dockerfile +++ b/services/seed/Dockerfile @@ -7,6 +7,7 @@ COPY packages ./packages COPY schema ./schema COPY services/seed ./services/seed COPY bootstrap ./bootstrap +COPY infra/package.json ./infra/package.json RUN mkdir -p test/e2e RUN pnpm install --frozen-lockfile RUN pnpm --filter @enspack/core --filter @enspack/torrent --filter @enspack/seed build