diff --git a/README.md b/README.md index b795ccf..6157b3b 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,20 @@ A GraphQL server that exposes [Mina archive-node](https://docs.minaprotocol.com/ ```graphql query { events(input: { address: "B62..." }) { - blockInfo { height stateHash timestamp chainStatus } - eventData { data } - transactionInfo { status hash memo } + blockInfo { + height + stateHash + timestamp + chainStatus + } + eventData { + data + } + transactionInfo { + status + hash + memo + } } } ``` @@ -22,11 +33,11 @@ The full surface lives in [`schema.graphql`](./schema.graphql). Pick the path that matches your situation. Each one is fully covered in [`docs/getting-started.md`](./docs/getting-started.md). -| Path | When to use it | -| --- | --- | -| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. | +| Path | When to use it | +| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| **[npm](./docs/getting-started.md#path-a--npm-bring-your-own-database)** | You already have an archive-node Postgres reachable. Lightest weight. | | **[Prebuilt Docker image](./docs/getting-started.md#path-b--prebuilt-docker-image-bring-your-own-database)** | You have a Postgres reachable but don't want a Node toolchain locally. | -| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. | +| **[Docker Compose + DB snapshot](./docs/getting-started.md#path-c--docker-compose-with-database-snapshot)** | No archive-node DB available — Compose stands one up from a snapshot. | ```sh # the 30-second taste (Path A) @@ -41,31 +52,31 @@ PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \ `PG_CONN` is the only required environment variable. The most common knobs: -| Variable | Default | Description | -| --- | --- | --- | -| `PG_CONN` | *(required)* | Postgres connection string for the archive-node DB | -| `PORT` | `8080` | Port the GraphQL server listens on | -| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` | -| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection | -| `ENABLE_LOGGING` | `false` | Enable request logging | -| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` | -| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | -| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` | +| Variable | Default | Description | +| ---------------------- | ------------ | -------------------------------------------------- | +| `PG_CONN` | _(required)_ | Postgres connection string for the archive-node DB | +| `PORT` | `8080` | Port the GraphQL server listens on | +| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` | +| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection | +| `ENABLE_LOGGING` | `false` | Enable request logging | +| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` | +| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | +| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` | Full reference, including HA / multi-host `PG_CONN` syntax, in [`docs/getting-started.md#configuration`](./docs/getting-started.md#configuration). ## Development -| Command | What it does | -| --- | --- | -| `npm run dev` | Run the server with hot reload (reads `.env`) | -| `npm run build` | Compile TypeScript to `build/` | -| `npm run start` | Run the compiled server | -| `npm run lint` | ESLint over `*.ts` | -| `npm run test:unit` | Unit tests (no DB required) | -| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) | -| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` | -| `npm run benchmark` | Artillery load test against a running server | +| Command | What it does | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `npm run dev` | Run the server with hot reload (reads `.env`) | +| `npm run build` | Compile TypeScript to `build/` | +| `npm run start` | Run the compiled server | +| `npm run lint` | ESLint over `*.ts` | +| `npm run test:unit` | Unit tests (no DB required) | +| `npm run test` | Full test suite — needs a running [Lightnet](https://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet) | +| `npm run codegen` | Regenerate `src/resolvers-types.ts` from `schema.graphql` | +| `npm run benchmark` | Artillery load test against a running server | Running the full suite requires Lightnet plus a populated DB: @@ -108,6 +119,10 @@ deployment. For SLOs, capacity guidance, what to monitor, and incident response, see the [operations runbook](./docs/runbook.md). +## Deployment + +Reference Kubernetes and production Docker Compose manifests — with liveness/readiness probes, resource limits, autoscaling, and a hardened pod security context — live in [`deploy/`](./deploy/). Read [`docs/security.md`](./docs/security.md) for the deployment contract (TLS gateway, read-only DB role, private Postgres). + ## Contributing - AI coding agents: read [`AGENTS.md`](./AGENTS.md) first. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..8c8e95a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,70 @@ +# Reference deployment artifacts + +Opinionated starting points for running the Archive Node API in production. They +are references to adapt, not turnkey configs — review image tags, sizing, and +secret management for your environment. Read [`docs/security.md`](../docs/security.md) +first for the deployment contract (TLS gateway, read-only DB role, private +Postgres). + +> **These manifests require image `>= 1.0.0`** and are pinned to it. The +> readiness probe and `/metrics` scrape target endpoints that `0.0.x` images do +> not serve — on an older image the readiness probe 404s forever, no pod goes +> Ready, and the Service ends up with no endpoints at all. + +## Kubernetes — [`kubernetes.yaml`](./kubernetes.yaml) + +A `Deployment` + `Service` + `HorizontalPodAutoscaler` + +`PodDisruptionBudget` with production defaults baked in. Create the Postgres +`Secret` separately so routine `kubectl apply` runs never overwrite a real +connection string with the example placeholder. + +- **Liveness** probe on `/healthcheck` (process up) and **readiness** probe on + `/readiness` (database reachable) — a node with a dead DB stops receiving + traffic without being restarted. Readiness tolerates up to 60s of database + slowness before removing endpoints. +- **Resource** requests/limits and a 2 to 6 replica HPA on CPU; the HPA owns the + replica count. +- Hardened pod: non-root, `readOnlyRootFilesystem`, + `allowPrivilegeEscalation: false`, all capabilities dropped, `RuntimeDefault` + seccomp, no mounted service account token, and a scratch `emptyDir` mounted at + `/tmp`. +- Prometheus scrape annotations pointing at `/metrics`, with + `ENABLE_METRICS=true`. +- `preStop` sleeps 15s before SIGTERM, then `terminationGracePeriodSeconds: 45` + leaves room for the app's own 10s `SHUTDOWN_TIMEOUT_MS` drain before SIGKILL. + +```sh +# once, with your real connection string (never committed): +kubectl create secret generic archive-node-api \ + --from-literal=PG_CONN='postgres://archive_api_ro:...@postgres:5432/archive' + +kubectl apply -f deploy/kubernetes.yaml +``` + +Put a TLS-terminating Ingress/gateway in front and set **`TRUST_PROXY` to the +number of hops** it adds. The gateway must set `X-Forwarded-For`, but the API +ignores that header while `TRUST_PROXY=0` (the safe default for a directly +exposed server), so leaving it unset behind an ingress collapses every client +into a single rate-limit bucket. See [`docs/security.md`](../docs/security.md). + +## Docker Compose — [`docker-compose.prod.yml`](./docker-compose.prod.yml) + +Runs only the published image against an external Postgres (contrast with the +repo-root `docker-compose.yml`, which is for local dev with a bundled DB). +Compose surfaces the image's `/healthcheck` as container health for images +`>= 1.0.0`; this is a liveness check, not database-aware readiness. + +```sh +PG_CONN='postgres://archive_api_ro:...@db:5432/archive' \ + docker compose -f deploy/docker-compose.prod.yml up -d +``` + +## Sizing + +The bottleneck is Postgres, not this server. Note that listing several hosts in +`PG_CONN` gives you **failover, not read throughput** — the client sticks to the +first host and only moves on when the connection fails. To spread reads across +replicas, put a load balancer (PgBouncer, HAProxy, a managed reader endpoint) in +front of Postgres and point `PG_CONN` at it. See the benchmark note in the root +[`README.md`](../README.md#hardware-requirements) and use `npm run benchmark` to +size your own deployment. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..d97720f --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,32 @@ +# Production-shaped Compose for the Archive Node API alone (bring your own +# Postgres). Unlike the repo-root docker-compose.yml — which stands up Postgres + +# Jaeger for local development — this runs only the published image against an +# external, read-only archive database. See deploy/README.md and docs/security.md. +services: + archive-node-api: + # Pinned, not :latest. Readiness/metrics endpoints require >= 1.0.0. + image: ghcr.io/o1-labs/archive-node-api:1.0.0 + restart: unless-stopped + ports: + - '8080:8080' + environment: + # Required. Point at a read-only Postgres role (see docs/security.md). + PG_CONN: ${PG_CONN:?set PG_CONN to your archive-node Postgres connection string} + PORT: '8080' + NODE_OPTIONS: '--max-old-space-size=768' + # Browser clients are cross-origin and CANNOT reach this API unless their + # origin is allowed here — and they fail silently, with nothing in the + # server logs. Set this deliberately (see docs/security.md): + # '*' public API any browser may call + # 'https://app.example.com' known, fixed front-ends (comma-separate) + # Unset means same-origin only, which blocks every browser client. + CORS_ORIGIN: '*' + ENABLE_METRICS: 'true' + # Proxy hops in front of the API. Rate limiting keys on the socket address + # while this is 0, so every client behind a proxy shares one bucket. + TRUST_PROXY: '0' + RATE_LIMIT_MAX: '1200' + # Memory cap (Compose v2). NODE_OPTIONS keeps V8 below this container limit. + mem_limit: 1g + # Images >= 1.0.0 ship a HEALTHCHECK against /healthcheck; Compose surfaces + # it as the container health status. diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml new file mode 100644 index 0000000..bac7fe5 --- /dev/null +++ b/deploy/kubernetes.yaml @@ -0,0 +1,175 @@ +# Reference Kubernetes manifest for the Archive Node API. +# +# This is a starting point, not a turnkey production deploy — review the image +# tag, resource sizing, and secret management for your cluster. Create the +# archive-node-api Secret separately; see deploy/secret.example.yaml and +# deploy/README.md. +# +# REQUIRES image >= 1.0.0. The readiness probe below targets /readiness, which +# earlier images do not serve: on 0.0.x the probe 404s forever, no pod ever goes +# Ready, and the Service is left with zero endpoints — a total outage. The +# /metrics scrape annotations need >= 1.0.0 for the same reason. The image is +# pinned accordingly; do not move it back to :latest. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: archive-node-api + labels: + app: archive-node-api +spec: + selector: + matchLabels: + app: archive-node-api + template: + metadata: + labels: + app: archive-node-api + annotations: + prometheus.io/scrape: 'true' + prometheus.io/port: '8080' + prometheus.io/path: /metrics + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + automountServiceAccountToken: false + containers: + - name: archive-node-api + # Pinned, not :latest — see the version requirement at the top. + image: ghcr.io/o1-labs/archive-node-api:1.0.0 + ports: + - containerPort: 8080 + env: + - name: PORT + value: '8080' + - name: PG_CONN + valueFrom: + secretKeyRef: + name: archive-node-api + key: PG_CONN + - name: NODE_OPTIONS + # Keep V8 below limits.memory so large responses fail in-process + # before the kernel OOM-kills the pod mid-request. + value: '--max-old-space-size=768' + # Browser clients are cross-origin and CANNOT reach this API unless + # their origin is allowed here — and they fail silently, with nothing + # in the server logs. Set this deliberately (see docs/security.md): + # '*' public API any browser may call + # 'https://app.example.com' known, fixed front-ends (comma-separate) + # Unset means same-origin only, which blocks every browser client. + - name: CORS_ORIGIN + value: '*' + - name: ENABLE_METRICS + value: 'true' + # Must be < terminationGracePeriodSeconds below, so the app finishes + # draining before the kubelet sends SIGKILL. + - name: SHUTDOWN_TIMEOUT_MS + value: '10000' + # Number of proxy hops in front of the API. Rate limiting keys on the + # socket address while this is 0, so every client behind an ingress + # shares one bucket; set it to the real hop count. + - name: TRUST_PROXY + value: '1' + - name: RATE_LIMIT_MAX + # Per client IP per 60s, counted per replica. The HPA moves + # replicas 2 to 6, so the effective global ceiling varies with scale. + value: '1200' + resources: + requests: + cpu: '500m' + memory: '512Mi' + limits: + # No CPU limit: CFS throttling hurts Node.js p99 latency. The CPU + # request above is the scheduling guarantee. + memory: '1Gi' + lifecycle: + preStop: + # Endpoint removal is async and concurrent with SIGTERM. Give + # kube-proxy/ingress time to stop routing here before Node closes. + exec: + command: ['/bin/sh', '-c', 'sleep 15'] + # Liveness: process is up. Readiness: the database is reachable. + livenessProbe: + httpGet: + path: /healthcheck + port: 8080 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readiness + port: 8080 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + successThreshold: 1 + startupProbe: + httpGet: + path: /healthcheck + port: 8080 + periodSeconds: 3 + failureThreshold: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + # 15s preStop + SHUTDOWN_TIMEOUT_MS + margin before SIGKILL. + terminationGracePeriodSeconds: 45 +--- +apiVersion: v1 +kind: Service +metadata: + name: archive-node-api + labels: + app: archive-node-api +spec: + type: ClusterIP + selector: + app: archive-node-api + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: archive-node-api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: archive-node-api + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: archive-node-api +spec: + minAvailable: 1 + selector: + matchLabels: + app: archive-node-api diff --git a/deploy/secret.example.yaml b/deploy/secret.example.yaml new file mode 100644 index 0000000..7d84275 --- /dev/null +++ b/deploy/secret.example.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: archive-node-api +type: Opaque +stringData: + # Point at a read-only Postgres role (see docs/security.md). Replace before use. + PG_CONN: 'postgres://archive_api_ro:CHANGE_ME@postgres:5432/archive'