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
43 changes: 43 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# VeriNode-Backend developer tasks.
#
# The `localnet` target brings up a one-command local development environment
# so new contributors don't have to wire Postgres, the OTel collector,
# Prometheus and Grafana by hand. See deploy/localnet/README.md.

COMPOSE := docker compose -f deploy/localnet/docker-compose.yml
LOCALNET_DB_ENV := DB_HOST=localhost DB_PORT=$(or $(LOCALNET_PG_PORT),5432) DB_USER=verinode DB_PASSWORD=verinode DB_NAME=verinode

.DEFAULT_GOAL := help

.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'

.PHONY: localnet
localnet: ## Boot the full local stack (API + Postgres + OTel + Prometheus + Grafana) and seed data
$(COMPOSE) up -d --build --wait
$(MAKE) localnet-seed
$(MAKE) localnet-mock
@echo ""
@echo "localnet is up:"
@echo " API http://localhost:3000 (/metrics for Prometheus scrape)"
@echo " Prometheus http://localhost:9090"
@echo " Grafana http://localhost:3001 (anonymous viewer; login admin/admin)"
@echo " Postgres localhost:5432 (verinode / verinode)"

.PHONY: localnet-seed
localnet-seed: ## Seed validators / stakes / reputations / pending rewards (idempotent)
$(LOCALNET_DB_ENV) npx ts-node scripts/seed-localnet.ts

.PHONY: localnet-mock
localnet-mock: ## Generate mock heartbeat + reward telemetry for the seeded validators
$(LOCALNET_DB_ENV) npx ts-node scripts/mock-telemetry.ts

.PHONY: localnet-logs
localnet-logs: ## Tail all localnet service logs
$(COMPOSE) logs -f

.PHONY: localnet-clean
localnet-clean: ## Tear down the stack and remove all state (volumes)
$(COMPOSE) down -v --remove-orphans
53 changes: 53 additions & 0 deletions deploy/localnet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# VeriNode localnet

A one-command local development environment for the VeriNode backend, so a new
contributor can go from a fresh clone to a running, populated, observable stack
in minutes instead of days.

```bash
make localnet # build + boot everything, then seed data
make localnet-clean # tear it all down and remove state
```

## What comes up

| Service | URL / port | Purpose |
|---|---|---|
| API | http://localhost:3000 | The backend (`/metrics` for Prometheus) |
| Postgres (TimescaleDB) | localhost:5432 | `verinode` / `verinode`; migrations auto-applied |
| OTel collector | localhost:4317 (OTLP gRPC) | Receives traces from the API |
| Prometheus | http://localhost:9090 | Scrapes the API `/metrics` + collector |
| Grafana | http://localhost:3001 | Pre-provisioned Prometheus datasource + the repo's dashboards |

Migrations under `src/database/migrations/` are mounted into the Postgres
container's `docker-entrypoint-initdb.d`, so they run in order on first boot
(the image is `timescale/timescaledb-ha` because `002_uptime_schema.sql`
requires TimescaleDB + pg_cron). Grafana loads the existing dashboards from
`deploy/observability/` and `deploy/monitoring/` via file provisioning.

## Seed data

Run automatically by `make localnet` (and re-runnable on their own):

- `make localnet-seed` — provisions a bond pool and **8 validators**, each with
a stake, a reputation score, and a pending reward balance.
- `make localnet-mock` — emits a window of `uptime_heartbeat` rows and a few
`reward_tx` rows per validator, so the time-series panels have live data.

## A note on scope

The original issue was framed as a blockchain **validator testnet** (genesis
file, pre-funded accounts, mock attestations). This repo is the ROSCA
savings-circle API backend, which has no genesis/accounts primitives — so, per
the maintainer's confirmation on the issue, those steps are mapped to their
real equivalents here: the **seed script** provisions validator identities with
stakes/reputations/rewards (the "pre-funded accounts" analogue), and the
**mock-telemetry generator** produces heartbeat/reward data (the "mock
attestation" analogue). Everything is grounded in tables that actually exist
(`bond_pools`, `validator_stakes`, `reputations`, `reward_pending_amounts`,
`reward_tx`, `uptime_heartbeat`).

## Requirements

- Docker + Docker Compose v2 (`up --wait` support)
- Node.js (for the seed/mock scripts, run on the host via `ts-node`)
102 changes: 102 additions & 0 deletions deploy/localnet/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# VeriNode local development stack ("localnet").
#
# One command (`make localnet`) brings up the full backend with its
# dependencies and monitoring, so a new contributor does not have to wire
# Postgres, the OTel collector, Prometheus and Grafana by hand.
#
# NOTE on scope: this repo is the ROSCA (savings-circle) API backend, not an
# L1 validator client. "Validators" here are the node/validator identities the
# backend tracks (bond_pools, validator_stakes, reputations); the seed script
# provisions 8 of them with stakes + balances (the "pre-funded accounts"
# equivalent), and the mock generator emits heartbeat/reward telemetry (the
# "mock attestation" equivalent). See README.md in this directory.

name: verinode-localnet

services:
postgres:
# timescaledb-ha bundles TimescaleDB + pg_cron, both required by the
# uptime_heartbeat migration (002_uptime_schema.sql). pg_cron must be
# preloaded (and pointed at the app database) BEFORE initdb runs the
# migrations, or CREATE EXTENSION pg_cron aborts the init.
image: timescale/timescaledb-ha:pg16
command:
- -c
- shared_preload_libraries=timescaledb,pg_cron
- -c
- cron.database_name=verinode
environment:
POSTGRES_USER: verinode
POSTGRES_PASSWORD: verinode
POSTGRES_DB: verinode
ports:
# Override with LOCALNET_PG_PORT if 5432 is taken on the host.
- "${LOCALNET_PG_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# Migrations are pure, idempotent SQL — run them on first init, in
# numeric order, before anything connects.
- ../../src/database/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U verinode -d verinode"]
interval: 3s
timeout: 5s
retries: 20

otel-collector:
image: otel/opentelemetry-collector-contrib:0.109.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "8889:8889" # Prometheus exporter (collector's own metrics)

api:
build:
context: ../..
dockerfile: Dockerfile
environment:
NODE_ENV: development
PORT: "3000"
DB_HOST: postgres
DB_PORT: "5432"
DB_USER: verinode
DB_PASSWORD: verinode
DB_NAME: verinode
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
OTEL_SERVICE_NAME: verinode-api
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started

prometheus:
image: prom/prometheus:v2.54.1
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
depends_on:
- api

grafana:
image: grafana/grafana:11.2.0
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
ports:
- "3001:3000"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus

volumes:
pgdata:
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"title": "VeriNode Configuration Management",
"panels": [
{ "title": "Config reload successes", "expr": "sum(rate(verinode_config_reload_total[5m]))" },
{ "title": "Config reload failures", "expr": "sum(rate(verinode_config_reload_failed_total[5m]))" },
{ "title": "Config reload P99", "expr": "histogram_quantile(0.99, sum(rate(verinode_config_reload_duration_ms_bucket[5m])) by (le))" }
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"title": "VeriNode Structured Logging",
"schemaVersion": 39,
"panels": [
{ "type": "timeseries", "title": "Log records by severity", "targets": [{ "expr": "sum(rate(log_records_total{service_name=\"verinode-backend\"}[5m])) by (severity_text)" }] },
{ "type": "timeseries", "title": "Log ingestion failures", "targets": [{ "expr": "rate(otelcol_receiver_refused_log_records[5m])" }] },
{ "type": "timeseries", "title": "HTTP server P99 latency", "targets": [{ "expr": "histogram_quantile(0.99, sum(rate(http_server_duration_seconds_bucket{service_name=\"verinode-backend\"}[5m])) by (le))" }] }
]
}
13 changes: 13 additions & 0 deletions deploy/localnet/grafana/provisioning/dashboards/dashboards.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: 1

providers:
- name: VeriNode localnet dashboards
orgId: 1
folder: VeriNode
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
10 changes: 10 additions & 0 deletions deploy/localnet/grafana/provisioning/datasources/datasource.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
apiVersion: 1

datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
30 changes: 30 additions & 0 deletions deploy/localnet/otel-collector-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Minimal OpenTelemetry collector for local development.
# Receives OTLP/gRPC traces from the API (OTEL_EXPORTER_OTLP_ENDPOINT points
# here), logs them to the collector's stdout for easy inspection, and exposes
# its own metrics for Prometheus to scrape.

receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317

processors:
batch: {}

exporters:
debug:
verbosity: normal
prometheus:
endpoint: 0.0.0.0:8889

service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
20 changes: 20 additions & 0 deletions deploy/localnet/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
global:
scrape_interval: 10s
evaluation_interval: 10s

scrape_configs:
# The VeriNode API exposes Prometheus text-format metrics at GET /metrics
# (see index.js and src/api/metrics/prometheus.ts).
- job_name: verinode-api
metrics_path: /metrics
static_configs:
- targets: ["api:3000"]

# The OTel collector's own Prometheus exporter.
- job_name: otel-collector
static_configs:
- targets: ["otel-collector:8889"]

- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
79 changes: 79 additions & 0 deletions scripts/mock-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Generate mock telemetry for the seeded validators.
*
* This is the ROSCA-backend equivalent of a "mock attestation generator": it
* emits a window of uptime_heartbeat rows (the TimescaleDB hypertable the node
* monitoring is built on) plus a few reward_tx rows per validator, so Grafana
* panels and API endpoints have live-looking time-series data to show.
*
* Idempotent-ish: re-running appends a fresh window of heartbeats.
*
* Usage: npx ts-node scripts/mock-telemetry.ts (DB_* env, defaults to localnet)
*/
import { Client } from 'pg';
import { validatorIds } from './seed-localnet';

const VALIDATOR_COUNT = Number(process.env.LOCALNET_VALIDATORS ?? 8);
const HEARTBEAT_MINUTES = Number(process.env.LOCALNET_HEARTBEAT_MINUTES ?? 60);

function dbConfig() {
return {
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
user: process.env.DB_USER ?? 'verinode',
password: process.env.DB_PASSWORD ?? 'verinode',
database: process.env.DB_NAME ?? 'verinode',
};
}

async function main(): Promise<void> {
const client = new Client(dbConfig());
await client.connect();
try {
const validators = validatorIds(VALIDATOR_COUNT);
const now = Date.now();
let heartbeats = 0;
let rewards = 0;

for (const [idx, v] of validators.entries()) {
// One heartbeat per minute over the window.
for (let m = HEARTBEAT_MINUTES; m >= 0; m--) {
const ts = new Date(now - m * 60_000).toISOString();
// Deterministic-ish variation: mostly up, occasional degraded.
const degraded = (m + idx) % 17 === 0;
const latency = 20 + ((m * 7 + idx * 3) % 40) + (degraded ? 120 : 0);
const status = degraded ? 'degraded' : 'up';
const uptimePct = degraded ? 98.5 : 100.0;
const blockHeight = 1_000_000 + (HEARTBEAT_MINUTES - m) * 12 + idx;
await client.query(
`INSERT INTO uptime_heartbeat
(time, node_id, latency_ms, status, uptime_pct, block_height)
VALUES ($1, $2, $3, $4, $5, $6)`,
[ts, v, latency, status, uptimePct, blockHeight],
);
heartbeats++;
}

// A couple of reward transactions per validator (references reward_pending_amounts).
for (let r = 0; r < 3; r++) {
await client.query(
`INSERT INTO reward_tx (node_id, amount) VALUES ($1, $2)`,
[v, (0.5 + r * 0.25).toFixed(7)],
);
rewards++;
}
}

console.log(
`Generated ${heartbeats} heartbeat rows and ${rewards} reward_tx rows ` +
`across ${VALIDATOR_COUNT} validators (${HEARTBEAT_MINUTES}m window).`,
);
} finally {
await client.end();
}
}

main().catch((err) => {
console.error('[mock-telemetry] failed:', err);
process.exit(1);
});
Loading
Loading