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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
**/node_modules
**/dist
.git
**/.ponder
.env
**/.env
!**/.env.example
**/*.log
bootstrap/state.json
bootstrap/downloads
21 changes: 21 additions & 0 deletions .github/workflows/nightly-health.yml
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions bootstrap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions bootstrap/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions bootstrap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>/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 |

Expand Down
1 change: 1 addition & 0 deletions bootstrap/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package>/state.json),
ENSPACK_ENS_VERSION (v1|v2; default v2 on sepolia, v1 on mainnet),
ENSPACK_ENSV2_* (Universal Resolver / factory / implementations)
`;
Expand Down
11 changes: 10 additions & 1 deletion bootstrap/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand Down Expand Up @@ -97,7 +106,7 @@ export async function runCli(argv: string[], opts: RunCliOpts = {}): Promise<num
}

const downloads = parsed.downloads ?? join(root, "downloads");
const statePath = opts.statePath ?? join(root, "state.json");
const statePath = opts.statePath ?? bootstrapStatePath(env, root);
const results = await runBootstrap(
{
config,
Expand Down
18 changes: 18 additions & 0 deletions bootstrap/test/cli-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { bootstrapStatePath } from "../src/cli.js";

describe("bootstrapStatePath", () => {
it("defaults to <package>/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",
);
});
});
59 changes: 59 additions & 0 deletions infra/.env.example
Original file line number Diff line number Diff line change
@@ -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 .<root> (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=
7 changes: 7 additions & 0 deletions infra/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
15 changes: 15 additions & 0 deletions infra/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions infra/README.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading