diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..1eaff22c --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,141 @@ +# Build + publish the four Nexus container images to Docker Hub. +# +# Trigger: version tags (v*) or manual dispatch. Correctness is gated by +# ci.yml / go-ci.yml on every PR — this workflow gates PUBLISHING: +# Gate A (in-Dockerfile): libhs HSSelfTest during build + binary tag check. +# Gate B (here): hs-selfcheck executed inside the built runtime image — +# proves hs_compile/hs_alloc_scratch/hs_scan work in the exact +# environment we ship. "Build succeeded" alone would miss a +# FAT_RUNTIME=ON libhs that silently never scans (PII passthrough). +# Gate C (here): full-stack compose smoke — db-init seeds, all four services +# report healthy, the runtime hs-selfcheck confirms the scan engine, +# and the seeded virtual key authenticates + runs the request +# pipeline (see deploy/docker/smoke-redaction.sh). Credential-free; +# full PII-redaction E2E is tests/scripts/smoke-gateway.py against a +# credentialled deployment. +# +# Secrets required: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (alphabitcore org). +# Arch: linux/amd64 (arm64 is a planned fast-follow — per-arch libhs builds +# make it additive; see deploy/docker/ai-gateway Dockerfile notes). + +name: docker-publish + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Image tag to publish (defaults to the git ref name)" + required: false + +env: + REGISTRY_ORG: alphabitcore + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - image: nexus-hub + dockerfile: packages/nexus-hub/Dockerfile + vectorscan: false + - image: nexus-console + dockerfile: deploy/docker/console/Dockerfile + vectorscan: false + - image: nexus-ai-gateway + dockerfile: packages/ai-gateway/Dockerfile + vectorscan: true + - image: nexus-compliance-proxy + dockerfile: packages/compliance-proxy/Dockerfile + vectorscan: true + # Deploy-utility image (not a product image) — the Helm db-init hook + # needs schema+seed in a runnable image; compose mounts host source. + - image: nexus-db-migrate + dockerfile: tools/db-migrate/Dockerfile + vectorscan: false + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image (amd64) + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + load: true + tags: ${{ env.REGISTRY_ORG }}/${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,scope=${{ matrix.image }},mode=max + + - name: "Gate B: vectorscan runtime self-check" + if: ${{ matrix.vectorscan }} + run: | + docker run --rm --entrypoint hs-selfcheck \ + "$REGISTRY_ORG/${{ matrix.image }}:ci" + + - name: Export image for downstream jobs + run: | + mkdir -p /tmp/images + docker save "$REGISTRY_ORG/${{ matrix.image }}:ci" \ + -o "/tmp/images/${{ matrix.image }}.tar" + + - uses: actions/upload-artifact@v4 + with: + name: image-${{ matrix.image }} + path: /tmp/images/${{ matrix.image }}.tar + retention-days: 1 + + compose-smoke: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + pattern: image-* + path: /tmp/images + merge-multiple: true + + - name: Load built images + run: for f in /tmp/images/*.tar; do docker load -i "$f"; done + + - name: "Gate C: full-stack smoke (health + PII redaction path)" + run: | + ./scripts/compose-init.sh + NEXUS_IMAGE_TAG=ci \ + deploy/docker/smoke-redaction.sh + + push: + needs: compose-smoke + runs-on: ubuntu-latest + strategy: + matrix: + image: [nexus-hub, nexus-console, nexus-ai-gateway, nexus-compliance-proxy, nexus-db-migrate] + steps: + - uses: actions/download-artifact@v4 + with: + name: image-${{ matrix.image }} + path: /tmp/images + + - name: Load built image + run: docker load -i /tmp/images/${{ matrix.image }}.tar + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Tag + push + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + SHA_TAG="${GITHUB_SHA::12}" + for t in "$TAG" "$SHA_TAG" latest; do + docker tag "$REGISTRY_ORG/${{ matrix.image }}:ci" \ + "$REGISTRY_ORG/${{ matrix.image }}:$t" + docker push "$REGISTRY_ORG/${{ matrix.image }}:$t" + done diff --git a/.gitignore b/.gitignore index 7147b12a..7760f381 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,9 @@ worktrees/ # Local tool installs (golangci-lint etc.) — not committed .tooling/ + +# Generated by scripts/compose-init.sh — full-stack compose secrets +deploy/docker/.env.compose + +# Helm subchart archives — fetched via `helm dependency build` (Chart.lock pins versions) +deploy/helm/nexus-gateway/charts/*.tgz diff --git a/README.md b/README.md index 3cfd403f..fbf24502 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,35 @@ The lateral dotted arrow is the **attestation handoff**: the Agent always egress |---|---|---| | **AWS Marketplace AMI / single-instance appliance** | `cd nexus-ami && ./build.sh` — bakes binaries + UI + Prisma + nginx + Postgres + Valkey + NATS into one AL2023 image via Packer | [`nexus-ami/README.md`](./nexus-ami/README.md) for build steps, [`docs/developers/architecture/cross-cutting/deployment/ami-appliance-architecture.md`](./docs/developers/architecture/cross-cutting/deployment/ami-appliance-architecture.md) for design | | **Local development** | docker-compose + `./scripts/dev-start.sh` (Postgres + Valkey + NATS) and per-service `go run ./cmd//` | See **Quick start** below | +| **Docker (dev / demo)** | `./scripts/compose-init.sh && docker compose -f docker-compose.full.yml --env-file deploy/docker/.env.compose up -d` — full stack from the published `alphabitcore/nexus-*` images | See **Docker images** below | +| **Kubernetes / Helm (production)** | `helm install nexus deploy/helm/nexus-gateway` — 4 service deployments + embedded or external Postgres/Valkey/NATS | [`deploy/helm/nexus-gateway/README.md`](./deploy/helm/nexus-gateway/README.md) | | **VMware / KVM image / bare-metal appliance** | Reuses the same `install.sh` + `harden.sh` from `nexus-ami/scripts/` under a different Packer builder | Future | -| **Container / Kubernetes** | Out of scope for the appliance form factor — separate product line | Future | + +### Docker images + +Four images ship to Docker Hub under the `alphabitcore` org (tag-triggered by +`.github/workflows/docker-publish.yml`; dependency stores — Postgres, Valkey, +NATS — are pulled upstream, never rebuilt): + +| Image | Contents | Ports | +|---|---|---| +| `alphabitcore/nexus-console` | nginx + control-plane + admin UI (the console) | 80, 443, 3001 | +| `alphabitcore/nexus-hub` | Hub (node lifecycle, config sync, MQ consumers) | 3060 | +| `alphabitcore/nexus-ai-gateway` | AI Gateway — Vectorscan-accelerated compliance scanning | 3050 | +| `alphabitcore/nexus-compliance-proxy` | TLS-bump compliance proxy — Vectorscan-accelerated | 3128, 3040, 9090 | + +```bash +docker pull alphabitcore/nexus-ai-gateway:latest +# verify the scanning engine inside the shipped image (never trust "build succeeded"): +docker run --rm --entrypoint hs-selfcheck alphabitcore/nexus-ai-gateway:latest +# single service against your own infra (vars documented in .env.example): +docker run --env-file ai-gateway.env -p 3050:3050 alphabitcore/nexus-ai-gateway:latest +``` + +The ai-gateway and compliance-proxy images build libhs from source per-arch +with `FAT_RUNTIME=OFF` + `BUILD_AVX512=OFF` (portable baseline; see the +warning below) and hard-fail the build if the engine didn't link or its +self-test fails — a silent pure-Go RE2 fallback image can never ship. > **⚠ Building from source — Vectorscan binaries are CPU-microarchitecture-specific.** > The compliance scanner links **libhs (Vectorscan) statically with `FAT_RUNTIME=OFF`** — diff --git a/deploy/docker/ai-gateway/config.yaml.tpl b/deploy/docker/ai-gateway/config.yaml.tpl new file mode 100644 index 00000000..8ba680ba --- /dev/null +++ b/deploy/docker/ai-gateway/config.yaml.tpl @@ -0,0 +1,94 @@ +# Nexus AI Gateway — container-shape config template. +# Rendered by the image entrypoint via envsubst; service addresses default to +# the docker-compose / Helm service names. Secrets are NOT templated here — +# they stay blank and are loaded from the environment by the config loader +# (DATABASE_URL, REDIS_PASSWORD, ADMIN_KEY_HMAC_SECRET, +# CREDENTIAL_ENCRYPTION_KEY, INTERNAL_SERVICE_TOKEN). + +# Externally-reachable base URL clients use to reach this gateway (reported to +# the Thing Registry as staticInfo; the admin UI renders it). Required. +publicURL: "${NEXUS_AI_GATEWAY_PUBLIC_URL}" + +server: + port: 3050 + readTimeout: "30s" + writeTimeout: "360s" + +database: + url: "" # env DATABASE_URL + +redis: + mode: standalone + addrs: ["${NEXUS_REDIS_ADDR}"] + username: "" + password: "" # env REDIS_PASSWORD + db: 0 + sentinel: + masterName: "" + username: "" + password: "" + cluster: + maxRedirects: 8 + routeRandomly: false + readOnly: false + tls: + enabled: false + insecureSkipVerify: false + caFile: "" + certFile: "" + keyFile: "" + serverName: "" + poolSize: 200 + minIdleConns: 50 + maxRetries: 3 + dialTimeout: 5s + readTimeout: 3s + writeTimeout: 3s + poolTimeout: 4s + +auth: + hmacSecret: "" # env ADMIN_KEY_HMAC_SECRET + credentialMasterKey: "" # env CREDENTIAL_ENCRYPTION_KEY (64 hex chars) + credentialKeyMap: "" + internalServiceToken: "" # env INTERNAL_SERVICE_TOKEN + +log: + level: "${NEXUS_LOG_LEVEL}" + format: "json" + file: "" # empty = stdout (container-native logging) + +registry: + nexusHubUrl: "${NEXUS_HUB_URL}" + +mq: + driver: "nats" + nats: + url: "${NEXUS_NATS_URL}" + +cors: + enabled: false + allowedOrigins: [] + allowedMethods: ["GET", "POST", "OPTIONS"] + allowedHeaders: ["Content-Type", "Authorization", "x-nexus-virtual-key", "x-request-id"] + maxAgeSec: 600 + +cache: + enabled: true + ttl: 5m + prefix: "ai-gw:" + broker: true + +otel: + endpoint: "" + serviceName: "nexus-ai-gateway" + +observability: + latencyDetail: true + +routing: + defaultRetryPolicy: + maxAttemptsPerTarget: 1 + retryOn: ["network", "timeout", "429", "5xx"] + backoffInitial: 250ms + backoffMax: 5s + backoffJitter: 0.2 diff --git a/deploy/docker/ai-gateway/entrypoint.sh b/deploy/docker/ai-gateway/entrypoint.sh new file mode 100755 index 00000000..65a69acb --- /dev/null +++ b/deploy/docker/ai-gateway/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Nexus AI Gateway container entrypoint — renders the config template with the +# service addresses for this deployment, then execs the gateway. +set -eu + +: "${NEXUS_REDIS_ADDR:=valkey:6379}" +: "${NEXUS_NATS_URL:=nats://nats:4222}" +: "${NEXUS_HUB_URL:=http://nexus-hub:3060}" +: "${NEXUS_AI_GATEWAY_PUBLIC_URL:=http://localhost:3050}" +: "${NEXUS_LOG_LEVEL:=info}" +export NEXUS_REDIS_ADDR NEXUS_NATS_URL NEXUS_HUB_URL NEXUS_AI_GATEWAY_PUBLIC_URL NEXUS_LOG_LEVEL + +envsubst '${NEXUS_REDIS_ADDR} ${NEXUS_NATS_URL} ${NEXUS_HUB_URL} ${NEXUS_AI_GATEWAY_PUBLIC_URL} ${NEXUS_LOG_LEVEL}' \ + < /opt/nexus/ai-gateway.config.yaml.tpl > /etc/nexus/ai-gateway.config.yaml + +exec /usr/local/bin/ai-gateway -config /etc/nexus/ai-gateway.config.yaml diff --git a/deploy/docker/compliance-proxy/config.yaml.tpl b/deploy/docker/compliance-proxy/config.yaml.tpl new file mode 100644 index 00000000..fb56595a --- /dev/null +++ b/deploy/docker/compliance-proxy/config.yaml.tpl @@ -0,0 +1,180 @@ +# Nexus Compliance Proxy — container-shape config template. +# Rendered by the image entrypoint via envsubst; service addresses default to +# the docker-compose / Helm service names. Secrets stay blank and load from +# env (DATABASE_URL, REDIS_PASSWORD, INTERNAL_SERVICE_TOKEN, +# COMPLIANCE_PROXY_API_TOKEN). + +# Externally-reachable base URL for this proxy (reported to the Thing Registry +# as staticInfo). Required. +publicURL: "${NEXUS_COMPLIANCE_PROXY_PUBLIC_URL}" + +listener: + address: ":3128" + +ca: + certPath: "/etc/compliance-proxy/ca.crt" + keyPath: "/etc/compliance-proxy/ca.key" + +database: + url: "" # env DATABASE_URL + +redis: + mode: standalone + addrs: ["${NEXUS_REDIS_ADDR}"] + username: "" + password: "" # env REDIS_PASSWORD + db: 0 + sentinel: + masterName: "" + username: "" + password: "" + cluster: + maxRedirects: 8 + routeRandomly: false + readOnly: false + tls: + enabled: false + insecureSkipVerify: false + caFile: "" + certFile: "" + keyFile: "" + serverName: "" + poolSize: 200 + minIdleConns: 50 + maxRetries: 3 + dialTimeout: 5s + readTimeout: 3s + writeTimeout: 3s + poolTimeout: 4s + +accessControl: + sourceIpAllowlist: + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + domainAllowlist: + - "api.openai.com:443" + - "*.openai.com:443" + - "api.anthropic.com:443" + - "*.anthropic.com:443" + - "generativelanguage.googleapis.com:443" + - "aistudio.google.com:443" + - "api.deepseek.com:443" + - "api.x.ai:443" + - "api.moonshot.cn:443" + - "open.bigmodel.cn:443" + - "api.minimax.chat:443" + - "copilot-proxy.githubusercontent.com:443" + internalNetworkExceptions: [] + +connections: + maxConcurrentTunnels: 10000 + maxStreamsPerConnection: 100 + idleTimeout: "300s" + shutdownGracePeriod: "30s" + +upstream: + maxConnsPerHost: 100 + idleConnTimeout: "90s" + dialTimeout: "10s" + +limits: + requestBodyLimit: "10MB" + responseBodyLimit: "10MB" + sseBufferLimit: "8MB" + +log: + level: "${NEXUS_LOG_LEVEL}" + format: "json" + file: "" # empty = stdout (container-native logging) + +metrics: + address: ":9090" + +# Bound on all interfaces (unlike the AMI's loopback bind): the console's +# control-plane calls this runtime API cross-container. The port is only +# published on the internal compose/K8s network, never the host. +runtimeApi: + listenAddress: ":3040" + +mq: + driver: "nats" + nats: + url: "${NEXUS_NATS_URL}" + +registry: + nexusHubUrl: "${NEXUS_HUB_URL}" + +auth: + internalServiceToken: "" # env INTERNAL_SERVICE_TOKEN ([MUST MATCH] across services) + +compliance: + enabled: true + perHookTimeoutMs: 5000 + totalTimeoutMs: 15000 + parallelHooks: false + checkpointChars: 500 + redactionRulesPath: "" + rejectResponse: + defaultLevel: 1 + contactInfo: "Contact administrator" + hooks: + - implementationId: "keyword-filter" + name: "Default Keyword Filter" + priority: 10 + enabled: false + stage: "request" + failBehavior: "fail-open" + timeoutMs: 5000 + applicableIngress: "ALL" + config: + patterns: [] + caseSensitive: false + - implementationId: "pii-detector" + name: "Default PII Detector" + priority: 20 + enabled: false + stage: "request" + failBehavior: "fail-open" + timeoutMs: 5000 + applicableIngress: "ALL" + config: + types: ["email", "phone", "ssn", "credit_card"] + action: "reject_hard" + +alerting: + enabled: true + evalIntervalSec: 30 + webhook: + url: "" + headers: {} + timeoutSec: 10 + cooldown: + fireMinutes: 5 + resolveMinutes: 5 + persistenceDir: "/var/lib/nexus/alerting" + +audit: + enabled: true + batch: + size: 10 + flushIntervalMs: 500 + channelBufferSize: 1000 + adaptiveFlush: false + flushIntervalMinMs: 500 + flushIntervalMaxMs: 10000 + adaptiveBatchSize: false + batchSizeMin: 10 + batchSizeMax: 500 + ndjson: + enabled: true + dir: "/var/lib/nexus/audit-spool" + maxFileSizeMB: 100 + maxTotalSizeMB: 1000 + pinning: + exemptions: [] + autoExempt: + enabled: true + failureThreshold: 3 + windowSeconds: 3600 + exemptionDurationSeconds: 86400 diff --git a/deploy/docker/compliance-proxy/entrypoint.sh b/deploy/docker/compliance-proxy/entrypoint.sh new file mode 100755 index 00000000..50bce9e1 --- /dev/null +++ b/deploy/docker/compliance-proxy/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Nexus Compliance Proxy container entrypoint — renders the config template, +# ensures a MITM CA exists, then execs the proxy. +set -eu + +: "${NEXUS_REDIS_ADDR:=valkey:6379}" +: "${NEXUS_NATS_URL:=nats://nats:4222}" +: "${NEXUS_HUB_URL:=http://nexus-hub:3060}" +: "${NEXUS_COMPLIANCE_PROXY_PUBLIC_URL:=http://localhost:3128}" +: "${NEXUS_LOG_LEVEL:=info}" +export NEXUS_REDIS_ADDR NEXUS_NATS_URL NEXUS_HUB_URL NEXUS_COMPLIANCE_PROXY_PUBLIC_URL NEXUS_LOG_LEVEL + +envsubst '${NEXUS_REDIS_ADDR} ${NEXUS_NATS_URL} ${NEXUS_HUB_URL} ${NEXUS_COMPLIANCE_PROXY_PUBLIC_URL} ${NEXUS_LOG_LEVEL}' \ + < /opt/nexus/compliance-proxy.config.yaml.tpl > /etc/nexus/compliance-proxy.config.yaml + +# The TLS-bump CA. Production deployments mount a real CA at +# /etc/compliance-proxy/ca.{crt,key}; for dev/demo we generate a self-signed +# one on first start so the container comes up without ceremony. +CA_CERT=/etc/compliance-proxy/ca.crt +CA_KEY=/etc/compliance-proxy/ca.key +if [ ! -f "$CA_CERT" ] || [ ! -f "$CA_KEY" ]; then + echo "compliance-proxy: no CA found — generating a self-signed MITM CA (dev/demo only; mount a real CA for production)" + # The cert issuer requires an EC private key (P-256) and a path-length-0 + # basic constraint so a leaked CA key cannot mint subordinate CAs. + openssl ecparam -name prime256v1 -genkey -noout -out "$CA_KEY" + openssl req -x509 -new -key "$CA_KEY" -days 825 -out "$CA_CERT" \ + -subj "/CN=Nexus Compliance Proxy CA/O=Nexus Gateway (dev)" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" + chmod 0600 "$CA_KEY" +fi + +exec /usr/local/bin/nexus-compliance-proxy -config /etc/nexus/compliance-proxy.config.yaml diff --git a/deploy/docker/console/Dockerfile b/deploy/docker/console/Dockerfile new file mode 100644 index 00000000..a2c9386f --- /dev/null +++ b/deploy/docker/console/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +# Nexus Console — the admin console in one image: nginx (TLS, SPA, reverse +# proxy) + the control-plane binary (admin API + auth server on loopback +# :3001... exposed on :3001 for the Hub's authServer/JWKS calls) + the +# Vite-built control-plane-UI dist. +# +# Replaces the separate control-plane / control-plane-ui / nginx containers: +# one light image, one thing to version. Pure Go + static assets — no cgo. +# Build context: repository root. +# docker build -f deploy/docker/console/Dockerfile -t alphabitcore/nexus-console:test . + +FROM golang:1.25-alpine AS cp-builder +WORKDIR /app +# GOWORK=off: the image carries only this module + its sibling `replace` +# targets (../shared, ../nexus-agent-core), not the full go.work module set. +COPY packages/shared/ packages/shared/ +COPY packages/nexus-agent-core/ packages/nexus-agent-core/ +COPY packages/control-plane/ packages/control-plane/ +WORKDIR /app/packages/control-plane +RUN GOWORK=off CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /control-plane ./cmd/control-plane/ + +FROM node:20-alpine AS ui-builder +WORKDIR /app +COPY package.json package-lock.json ./ +# ui-shared is a workspace dependency of control-plane-ui — both manifests +# must exist before npm ci or the workspace resolve fails. +COPY packages/ui-shared/package.json packages/ui-shared/ +COPY packages/control-plane-ui/package.json packages/control-plane-ui/ +# --ignore-scripts: skip the root `prepare` (git-hooks setup) lifecycle — it is +# a dev convenience that needs scripts/ + a git checkout, neither present here. +RUN npm ci --ignore-scripts --workspace=packages/control-plane-ui --workspace=packages/ui-shared +COPY packages/ui-shared packages/ui-shared +COPY packages/control-plane-ui packages/control-plane-ui +# The UI build's `sync-locales` prestep runs ../../scripts/sync-locales.mjs. +COPY scripts/ scripts/ +RUN cd packages/control-plane-ui && npm run build + +FROM nginx:1.27-alpine +RUN apk add --no-cache ca-certificates openssl && \ + mkdir -p /etc/nexus /opt/nexus /var/lib/nexus/agentca /var/lib/nexus/authkeys && \ + rm -f /etc/nginx/conf.d/default.conf +COPY --from=cp-builder /control-plane /usr/local/bin/control-plane +COPY --from=ui-builder /app/packages/control-plane-ui/dist /opt/nexus/ui +COPY deploy/docker/console/nginx.conf.tpl /opt/nexus/nginx.conf.tpl +COPY deploy/docker/console/config.yaml.tpl /opt/nexus/control-plane.config.yaml.tpl +COPY deploy/docker/console/entrypoint.sh /usr/local/bin/console-entrypoint.sh +RUN chmod 0755 /usr/local/bin/console-entrypoint.sh + +# 80 http→https redirect · 443 console UI/API · 3001 control-plane direct +# (internal network only — the Hub validates tokens against its JWKS). +EXPOSE 80 443 3001 +ENTRYPOINT ["console-entrypoint.sh"] diff --git a/deploy/docker/console/config.yaml.tpl b/deploy/docker/console/config.yaml.tpl new file mode 100644 index 00000000..7f38d983 --- /dev/null +++ b/deploy/docker/console/config.yaml.tpl @@ -0,0 +1,96 @@ +# Nexus Control Plane — container-shape config template (console image). +# Rendered by the console entrypoint via envsubst; service addresses default +# to the docker-compose / Helm service names. Secrets stay blank and load +# from env (DATABASE_URL, REDIS_PASSWORD, INTERNAL_SERVICE_TOKEN, +# COMPLIANCE_PROXY_API_TOKEN, CREDENTIAL_ENCRYPTION_KEY, AUTH_SERVER_ISSUER). + +server: + port: 3001 + shutdownTimeout: "10s" + +database: + url: "" # env DATABASE_URL + maxConns: 25 + minConns: 5 + maxConnLifetime: "300s" + +redis: + mode: standalone + addrs: ["${NEXUS_REDIS_ADDR}"] + username: "" + password: "" # env REDIS_PASSWORD + db: 0 + sentinel: + masterName: "" + username: "" + password: "" + cluster: + maxRedirects: 8 + routeRandomly: false + readOnly: false + tls: + enabled: false + insecureSkipVerify: false + caFile: "" + certFile: "" + keyFile: "" + serverName: "" + poolSize: 200 + minIdleConns: 50 + maxRetries: 3 + dialTimeout: 5s + readTimeout: 3s + writeTimeout: 3s + poolTimeout: 4s + +log: + level: "${NEXUS_LOG_LEVEL}" + format: "json" + file: "" # empty = stdout (container-native logging) + +bff: + complianceProxyUrl: "${NEXUS_COMPLIANCE_PROXY_URL}" + aiGatewayUrl: "${NEXUS_AI_GATEWAY_URL}" + complianceProxyRuntimeUrl: "${NEXUS_COMPLIANCE_PROXY_URL}" + complianceProxyApiToken: "" # env COMPLIANCE_PROXY_API_TOKEN + +registry: + nexusHubUrl: "${NEXUS_HUB_URL}" + +auth: + internalServiceToken: "" # env INTERNAL_SERVICE_TOKEN + +crypto: + encryptionKey: "" # env CREDENTIAL_ENCRYPTION_KEY (64 hex chars) + encryptionPassphrase: "" + encryptionSalt: "" + credentialKeyMap: "" + production: true + +retention: + auditLogDays: 90 + adminAuditLogDays: 365 + metricRollupDays: 365 + agentAuditDays: 90 + +agent: + caDir: "/var/lib/nexus/agentca" + +otel: + endpoint: "" + serviceName: "nexus-control-plane" + +scheduler: + enabled: true + +mq: + driver: "nats" + nats: + url: "${NEXUS_NATS_URL}" + +# OIDC issuer for tokens minted by this Control Plane. Must match the URL +# clients reach the console on; blank here so the AUTH_SERVER_ISSUER env +# override hook fires (L3 > L2 per configuration-architecture.md). +authServer: + issuer: "" # env AUTH_SERVER_ISSUER + keystoreDir: "/var/lib/nexus/authkeys" diff --git a/deploy/docker/console/entrypoint.sh b/deploy/docker/console/entrypoint.sh new file mode 100755 index 00000000..f3996de4 --- /dev/null +++ b/deploy/docker/console/entrypoint.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Nexus Console container entrypoint — renders nginx + control-plane configs, +# ensures TLS material exists, then supervises both processes: if either +# exits, the container exits and the orchestrator restarts it. +set -eu + +: "${NEXUS_AI_GATEWAY_UPSTREAM:=http://nexus-ai-gateway:3050}" +: "${NEXUS_HUB_UPSTREAM:=http://nexus-hub:3060}" +: "${NEXUS_AI_GATEWAY_URL:=http://nexus-ai-gateway:3050}" +: "${NEXUS_COMPLIANCE_PROXY_URL:=http://nexus-compliance-proxy:3040}" +: "${NEXUS_HUB_URL:=http://nexus-hub:3060}" +: "${NEXUS_REDIS_ADDR:=valkey:6379}" +: "${NEXUS_NATS_URL:=nats://nats:4222}" +: "${NEXUS_LOG_LEVEL:=info}" +# The control-plane reads CONTROL_PLANE_PUBLIC_URL from the environment +# directly (required config); default it so a bare `docker run` still boots. +: "${CONTROL_PLANE_PUBLIC_URL:=https://localhost}" +export NEXUS_AI_GATEWAY_UPSTREAM NEXUS_HUB_UPSTREAM NEXUS_AI_GATEWAY_URL \ + NEXUS_COMPLIANCE_PROXY_URL NEXUS_HUB_URL NEXUS_REDIS_ADDR NEXUS_NATS_URL \ + NEXUS_LOG_LEVEL CONTROL_PLANE_PUBLIC_URL + +# Render configs. The nginx template only substitutes the two upstream vars — +# everything else ($host, $remote_addr, ...) is nginx syntax and must survive. +envsubst '${NEXUS_AI_GATEWAY_UPSTREAM} ${NEXUS_HUB_UPSTREAM}' \ + < /opt/nexus/nginx.conf.tpl > /etc/nginx/conf.d/nexus.conf +envsubst '${NEXUS_REDIS_ADDR} ${NEXUS_NATS_URL} ${NEXUS_HUB_URL} ${NEXUS_AI_GATEWAY_URL} ${NEXUS_COMPLIANCE_PROXY_URL} ${NEXUS_LOG_LEVEL}' \ + < /opt/nexus/control-plane.config.yaml.tpl > /etc/nexus/control-plane.config.yaml + +# TLS: mount a real cert/key at /etc/nexus/tls.{crt,key} for production; +# otherwise generate a self-signed pair so the console comes up for dev/demo. +if [ ! -f /etc/nexus/tls.crt ] || [ ! -f /etc/nexus/tls.key ]; then + echo "console: no TLS material found — generating self-signed cert (dev/demo only)" + openssl req -x509 -newkey rsa:2048 -nodes -days 825 \ + -keyout /etc/nexus/tls.key -out /etc/nexus/tls.crt \ + -subj "/CN=nexus-console/O=Nexus Gateway (dev)" + chmod 0600 /etc/nexus/tls.key +fi + +/usr/local/bin/control-plane -config /etc/nexus/control-plane.config.yaml & +CP_PID=$! + +# Wait (up to 60s) for the control-plane to serve /healthz before nginx +# starts proxying to it. +i=0 +until wget -q -O /dev/null http://127.0.0.1:3001/healthz 2>/dev/null; do + if ! kill -0 "$CP_PID" 2>/dev/null; then + echo "console: control-plane exited during startup" >&2 + exit 1 + fi + i=$((i + 1)) + if [ "$i" -gt 60 ]; then + echo "console: control-plane not ready after 60s" >&2 + kill -TERM "$CP_PID" 2>/dev/null || true + exit 1 + fi + sleep 1 +done +echo "console: control-plane ready" + +nginx -g 'daemon off;' & +NGINX_PID=$! + +shutdown() { + kill -TERM "$CP_PID" "$NGINX_PID" 2>/dev/null || true +} +trap shutdown TERM INT + +# Fail-fast supervision: exit when either process dies so the orchestrator +# restarts the whole console atomically. +while kill -0 "$CP_PID" 2>/dev/null && kill -0 "$NGINX_PID" 2>/dev/null; do + sleep 2 +done +shutdown +wait "$CP_PID" 2>/dev/null || true +wait "$NGINX_PID" 2>/dev/null || true +echo "console: a supervised process exited — stopping container" >&2 +exit 1 diff --git a/deploy/docker/console/nginx.conf.tpl b/deploy/docker/console/nginx.conf.tpl new file mode 100644 index 00000000..2236ccf6 --- /dev/null +++ b/deploy/docker/console/nginx.conf.tpl @@ -0,0 +1,136 @@ +# Nexus Console — nginx config template for the container form factor. +# Derived from nexus-ami/artifacts/configs/nginx-nexus.conf; the control-plane +# runs INSIDE this container (127.0.0.1:3001), while the AI Gateway and Hub +# upstreams are env-parameterized service names rendered by the entrypoint. +# Keep the two files' location blocks in lockstep when either changes. + +server { + listen 80 default_server; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl default_server; + server_name _; + + ssl_certificate /etc/nexus/tls.crt; + ssl_certificate_key /etc/nexus/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /opt/nexus/ui; + index index.html; + + client_max_body_size 32m; + + # Vite SPA fallback — every unmatched path serves index.html so the + # client-side router takes over. + location / { + try_files $uri $uri/ /index.html; + } + + # Admin API + auth-server endpoints (both live in the in-container + # control-plane on :3001). proxy_buffering off so the admin SSE surfaces + # stream instead of buffering into one late chunk. + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # SCIM 2.0 provisioning (Okta / Entra ID push user+group sync). + location /scim/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # AI Gateway ingress — one regex block covers every ingress wire format + # (/v1 OpenAI canonical, /v1beta Gemini, /openai/deployments Azure, + # /api/paas GLM). Regex so /api/paas wins over the plain /api/ prefix. + # proxy_buffering off + HTTP/1.1 so SSE streams chunk-by-chunk. + location ~ ^/(v1|v1beta|openai/deployments|api/paas)(/|$) { + proxy_pass ${NEXUS_AI_GATEWAY_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # Nexus Hub — remote endpoint-agent connectivity only (WebSocket + + # enrollment/thingclient HTTP fallback). Bearer-token gated by the Hub. + location /ws { + proxy_pass ${NEXUS_HUB_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location /api/internal/things/ { + proxy_pass ${NEXUS_HUB_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + location /.well-known/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + } + + # OAuth/OIDC auth-server endpoints — without this block /oauth/authorize + # falls through to the SPA try_files handler and the login flow loops. + location /oauth/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Auth-server pre-bearer endpoints (IdP list, password login). Without the + # proxy they return SPA HTML and the login page shows + # "Unable to load sign-in methods". + location /authserver/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health + readiness for LBs / orchestrators — served by the control-plane + # at the ROOT (/healthz, /ready), so proxy_pass omits a URI. + location = /healthz { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + } + + location = /ready { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + } +} diff --git a/deploy/docker/ecr-publish.sh b/deploy/docker/ecr-publish.sh new file mode 100755 index 00000000..944fea25 --- /dev/null +++ b/deploy/docker/ecr-publish.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Build + publish the Nexus images to PRIVATE Amazon ECR so the two arena Nexus +# boxes can pull them the same way every other gateway box pulls its image +# (decision of record: ECR/Option 1; public Docker Hub is separate + James-gated). +# +# This mirrors .github/workflows/docker-publish.yml's build recipe exactly (same +# 5 images, same Dockerfiles, same vectorscan gates) — the only difference is the +# registry (private ECR) and an immutable git-sha tag for run provenance. +# +# Gates before a push (same intent as the workflow): +# Gate B — vectorscan runtime self-check (hs-selfcheck) on the two scanning +# images. Proves hs_compile/hs_alloc_scratch/hs_scan work in the exact +# shipped image; a FAT_RUNTIME=ON libhs that silently never scans +# (PII passthrough) fails here. The two scanning images are NOT pushed +# unless this passes. +# +# Requires: awscli v2, docker (buildx), an AWS identity that can create/push to +# ECR in the target account. Run AFTER the build test is green (CLAUDE-CODE +# TASK 1) — a red scan path must never be published. +# +# Usage: +# AWS_ACCOUNT=511092106101 AWS_REGION=us-east-1 ./deploy/docker/ecr-publish.sh +# (TAG defaults to the current git sha; override with TAG=v1.2.3) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +: "${AWS_ACCOUNT:?set AWS_ACCOUNT (the arena AWS account id)}" +AWS_REGION="${AWS_REGION:-us-east-1}" +TAG="${TAG:-$(git rev-parse --short=12 HEAD)}" +REGISTRY="${AWS_ACCOUNT}.dkr.ecr.${AWS_REGION}.amazonaws.com" +DIGESTS_FILE="${DIGESTS_FILE:-/tmp/nexus-ecr-digests-${TAG}.txt}" + +# image | dockerfile | vectorscan(true|false) — identical to docker-publish.yml +IMAGES=( + "nexus-hub|packages/nexus-hub/Dockerfile|false" + "nexus-console|deploy/docker/console/Dockerfile|false" + "nexus-ai-gateway|packages/ai-gateway/Dockerfile|true" + "nexus-compliance-proxy|packages/compliance-proxy/Dockerfile|true" + "nexus-db-migrate|tools/db-migrate/Dockerfile|false" +) + +echo "→ registry=$REGISTRY tag=$TAG" + +# 1) ECR repos — idempotent, IMMUTABLE tags (a tag never silently changes what a +# box pulled; provenance stays honest). +for spec in "${IMAGES[@]}"; do + name="${spec%%|*}" + aws ecr describe-repositories --region "$AWS_REGION" --repository-names "$name" >/dev/null 2>&1 \ + || aws ecr create-repository --region "$AWS_REGION" --repository-name "$name" \ + --image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true >/dev/null +done + +# 2) login +aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$REGISTRY" + +: > "$DIGESTS_FILE" +for spec in "${IMAGES[@]}"; do + IFS='|' read -r name dockerfile vectorscan <<<"$spec" + local_ref="$REGISTRY/$name:$TAG" + echo "→ building $name ($dockerfile) vectorscan=$vectorscan" + docker build --platform linux/amd64 -f "$dockerfile" -t "$local_ref" . + + # Gate B: the two scanning images must prove the scan engine works at runtime + # BEFORE they are pushed. hs-selfcheck prints scanRC=0 matches=1 on success. + if [[ "$vectorscan" == "true" ]]; then + echo " Gate B: hs-selfcheck (runtime scan proof) …" + if ! docker run --rm --entrypoint hs-selfcheck "$local_ref"; then + echo " ✗ $name failed the vectorscan runtime self-check — NOT pushing." >&2 + echo " (a red scan engine ships PII unredacted; fix before publishing)" >&2 + exit 1 + fi + fi + + echo " pushing $local_ref" + docker push "$local_ref" + digest="$(aws ecr describe-images --region "$AWS_REGION" --repository-name "$name" \ + --image-ids imageTag="$TAG" --query 'imageDetails[0].imageDigest' --output text)" + echo "$name $REGISTRY/$name@$digest" | tee -a "$DIGESTS_FILE" +done + +echo "" +echo "✓ published ${#IMAGES[@]} images to $REGISTRY at tag $TAG" +echo " digests (record these in run provenance): $DIGESTS_FILE" +echo "" +echo "Arena Nexus boxes pull with:" +echo " aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $REGISTRY" +echo " docker pull " +echo "The pulling box needs the instance-role policy in deploy/docker/ecr-pull-policy.json." diff --git a/deploy/docker/ecr-pull-policy.json b/deploy/docker/ecr-pull-policy.json new file mode 100644 index 00000000..885db996 --- /dev/null +++ b/deploy/docker/ecr-pull-policy.json @@ -0,0 +1,28 @@ +{ + "_comment": "Minimal instance-role policy for the two arena Nexus boxes to PULL the Nexus images from private ECR. Attach to the boxes' instance profile. Pull-only: no push, no create, no delete. GetAuthorizationToken cannot be resource-scoped (AWS limitation); the layer/image reads are scoped to the five nexus-* repos. Replace / before applying.", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EcrAuthToken", + "Effect": "Allow", + "Action": "ecr:GetAuthorizationToken", + "Resource": "*" + }, + { + "Sid": "PullNexusImagesOnly", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability" + ], + "Resource": [ + "arn:aws:ecr:::repository/nexus-hub", + "arn:aws:ecr:::repository/nexus-console", + "arn:aws:ecr:::repository/nexus-ai-gateway", + "arn:aws:ecr:::repository/nexus-compliance-proxy", + "arn:aws:ecr:::repository/nexus-db-migrate" + ] + } + ] +} diff --git a/deploy/docker/hub/config.yaml.tpl b/deploy/docker/hub/config.yaml.tpl new file mode 100644 index 00000000..695efff6 --- /dev/null +++ b/deploy/docker/hub/config.yaml.tpl @@ -0,0 +1,96 @@ +# Nexus Hub — container-shape config template. +# Rendered by the image entrypoint via envsubst; service addresses default to +# the docker-compose / Helm service names. Secrets stay blank and load from +# env (DATABASE_URL, REDIS_PASSWORD, INTERNAL_SERVICE_TOKEN). + +server: + port: 3060 + readTimeout: 30s + writeTimeout: 30s + shutdownTimeout: 15s + +database: + url: "" # env DATABASE_URL + maxConns: 20 + minConns: 5 + +redis: + mode: standalone + addrs: ["${NEXUS_REDIS_ADDR}"] + username: "" + password: "" # env REDIS_PASSWORD + db: 0 + sentinel: + masterName: "" + username: "" + password: "" + cluster: + maxRedirects: 8 + routeRandomly: false + readOnly: false + tls: + enabled: false + insecureSkipVerify: false + caFile: "" + certFile: "" + keyFile: "" + serverName: "" + poolSize: 200 + minIdleConns: 50 + maxRetries: 3 + dialTimeout: 5s + readTimeout: 3s + writeTimeout: 3s + poolTimeout: 4s + +mq: + driver: "nats" + nats: + url: "${NEXUS_NATS_URL}" + +consumers: + enabled: true + batchSize: 100 + flushInterval: 5s + siem: + enabled: false + url: "" + headers: {} + format: "json" + batchSize: 200 + flushInterval: 5s + eventTypes: [] + +scheduler: + enabled: true + driftCheckInterval: 60s + identityEnrichInterval: 5m + enableAgentRollup: false + +auth: + internalServiceToken: "" # env INTERNAL_SERVICE_TOKEN + +# The auth server lives inside the console image's control-plane (:3001). +authServer: + url: "${NEXUS_CONSOLE_INTERNAL_URL}" + jwksURL: "${NEXUS_CONSOLE_INTERNAL_URL}/.well-known/jwks.json" + issuer: "${NEXUS_AUTH_ISSUER}" + +agentCA: + certFile: "" + keyFile: "" + dir: "/var/lib/nexus/agentca" + +otel: + enabled: false + endpoint: "" + +log: + level: "${NEXUS_LOG_LEVEL}" + format: "json" + file: "" # empty = stdout (container-native logging) + +hub: + id: "${NEXUS_HUB_ID}" + advertiseAddr: "${NEXUS_HUB_ADVERTISE_ADDR}" + allowedOrigins: [] diff --git a/deploy/docker/hub/entrypoint.sh b/deploy/docker/hub/entrypoint.sh new file mode 100755 index 00000000..97fe3f98 --- /dev/null +++ b/deploy/docker/hub/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Nexus Hub container entrypoint — renders the config template with the +# service addresses for this deployment, then execs the hub. +set -eu + +: "${NEXUS_REDIS_ADDR:=valkey:6379}" +: "${NEXUS_NATS_URL:=nats://nats:4222}" +: "${NEXUS_CONSOLE_INTERNAL_URL:=http://nexus-console:3001}" +: "${NEXUS_AUTH_ISSUER:=${NEXUS_CONSOLE_INTERNAL_URL}}" +: "${NEXUS_HUB_ID:=hub-docker-1}" +: "${NEXUS_HUB_ADVERTISE_ADDR:=http://nexus-hub:3060}" +: "${NEXUS_LOG_LEVEL:=info}" +export NEXUS_REDIS_ADDR NEXUS_NATS_URL NEXUS_CONSOLE_INTERNAL_URL \ + NEXUS_AUTH_ISSUER NEXUS_HUB_ID NEXUS_HUB_ADVERTISE_ADDR NEXUS_LOG_LEVEL + +envsubst '${NEXUS_REDIS_ADDR} ${NEXUS_NATS_URL} ${NEXUS_CONSOLE_INTERNAL_URL} ${NEXUS_AUTH_ISSUER} ${NEXUS_HUB_ID} ${NEXUS_HUB_ADVERTISE_ADDR} ${NEXUS_LOG_LEVEL}' \ + < /opt/nexus/nexus-hub.config.yaml.tpl > /etc/nexus/nexus-hub.config.yaml + +exec /usr/local/bin/nexus-hub --config /etc/nexus/nexus-hub.config.yaml diff --git a/deploy/docker/smoke-redaction.sh b/deploy/docker/smoke-redaction.sh new file mode 100755 index 00000000..d80f321f --- /dev/null +++ b/deploy/docker/smoke-redaction.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# smoke-redaction.sh — self-contained container smoke for docker-compose.full.yml. +# +# Verifies, WITHOUT any provider credential or external network, the things the +# container packaging is responsible for: +# 1. db-init completes (schema push + prod seed). +# 2. All four service containers report healthy. +# 3. The Vectorscan content-scanning engine actually works INSIDE the shipped +# ai-gateway + compliance-proxy runtime images (hs-selfcheck: hs_compile + +# hs_alloc_scratch + hs_scan → scanRC=0, matches>=1). This is the check +# that catches a FAT_RUNTIME=ON libhs that links but silently never scans. +# 4. The ai-gateway authenticates the seeded virtual key and runs the request +# pipeline (a request reaches the upstream-forward stage rather than +# bouncing at auth). +# +# What it deliberately does NOT do: enable a compliance hook and assert an +# actual redaction end-to-end. Hook enablement flows Admin-UI → Control Plane → +# Hub shadow → gateway (config-sync), and a real redaction path needs a +# reachable upstream provider. That full E2E belongs to tests/scripts/ +# smoke-gateway.py against a credentialled deployment, not to this +# credential-free packaging gate. +# +# Usage: [NEXUS_IMAGE_TAG=ci] deploy/docker/smoke-redaction.sh +# Leaves the stack DOWN on exit; set SMOKE_KEEP_UP=1 to keep it. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" +ENV_FILE=deploy/docker/.env.compose +COMPOSE=(docker compose -f docker-compose.full.yml --env-file "$ENV_FILE") + +[ -f "$ENV_FILE" ] || ./scripts/compose-init.sh +export NEXUS_IMAGE_TAG="${NEXUS_IMAGE_TAG:-latest}" +REGISTRY="${NEXUS_IMAGE_REGISTRY:-alphabitcore}" + +cleanup() { + if [ "${SMOKE_KEEP_UP:-0}" != "1" ]; then + "${COMPOSE[@]}" down -v >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +fail() { echo "[smoke] FAIL: $*" >&2; "${COMPOSE[@]}" ps; exit 1; } + +# ── Gate: Vectorscan engine works inside the shipped runtime images ────────── +echo "[smoke] verifying Vectorscan engine in the runtime images..." +for svc in ai-gateway compliance-proxy; do + out=$(docker run --rm --entrypoint hs-selfcheck "$REGISTRY/nexus-$svc:$NEXUS_IMAGE_TAG" 2>&1) \ + || fail "hs-selfcheck exited non-zero for $svc: $out" + echo "$out" | grep -q 'scanRC=0' && echo "$out" | grep -qE 'matches=[1-9]' \ + || fail "hs-selfcheck did not confirm a working scan for $svc: $out" + echo "[smoke] $svc: $out" +done + +# ── Bring the stack up ─────────────────────────────────────────────────────── +echo "[smoke] bringing the stack up (tag: $NEXUS_IMAGE_TAG)..." +"${COMPOSE[@]}" up -d + +echo "[smoke] waiting for services to report healthy..." +for i in $(seq 1 120); do + unhealthy=0 + for c in nexus-full-hub nexus-full-ai-gateway nexus-full-compliance-proxy nexus-full-console; do + st=$(docker inspect "$c" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || echo missing) + [ "$st" = healthy ] || unhealthy=1 + done + [ "$unhealthy" -eq 0 ] && break + [ "$i" -eq 120 ] && { "${COMPOSE[@]}" logs --tail 30; fail "services not all healthy after 10m"; } + sleep 5 +done +echo "[smoke] all four services healthy." + +# ── Gate: db-init seeded successfully ──────────────────────────────────────── +[ "$(docker inspect nexus-full-db-init --format '{{.State.ExitCode}}')" = 0 ] \ + || fail "db-init did not exit 0" +echo "[smoke] db-init completed (schema + seed)." + +# ── Gate: gateway authenticates the seeded VK and runs the pipeline ────────── +# The bootstrap seed mints a system-assistant VK; read its plaintext from the +# db-init log (local default). A request with it must pass auth and reach the +# pipeline — proven by anything other than 401/403 (a 502 "no upstream" is the +# expected result with no provider credential configured). +VK=$(docker logs nexus-full-db-init 2>&1 | sed -n 's/.*VK plaintext (local default): \(nvk_[A-Za-z0-9_]*\).*/\1/p' | head -1) +[ -n "$VK" ] || fail "could not read seeded VK from db-init log" +code=$(curl -sk -o /dev/null -w '%{http_code}' -X POST http://localhost:3050/v1/chat/completions \ + -H "Authorization: Bearer $VK" -H 'Content-Type: application/json' \ + -d '{"model":"gpt-4o","messages":[{"role":"user","content":"ping"}]}') +case "$code" in + 401|403) fail "gateway rejected the seeded VK (HTTP $code) — auth/pipeline broken" ;; + *) echo "[smoke] gateway authenticated VK and ran the pipeline (HTTP $code; 502 = no upstream configured, expected)." ;; +esac + +echo "[smoke] all gates green." diff --git a/deploy/helm/nexus-gateway/Chart.lock b/deploy/helm/nexus-gateway/Chart.lock new file mode 100644 index 00000000..52c9d6fc --- /dev/null +++ b/deploy/helm/nexus-gateway/Chart.lock @@ -0,0 +1,12 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 15.5.38 +- name: valkey + repository: https://charts.bitnami.com/bitnami + version: 2.4.7 +- name: nats + repository: https://nats-io.github.io/k8s/helm/charts/ + version: 1.3.16 +digest: sha256:99339bc3b3761d956b9559d438d419386f0d676e679c9095fa6b478b6be087da +generated: "2026-07-03T12:59:43.549064-04:00" diff --git a/deploy/helm/nexus-gateway/Chart.yaml b/deploy/helm/nexus-gateway/Chart.yaml new file mode 100644 index 00000000..6c54ab7e --- /dev/null +++ b/deploy/helm/nexus-gateway/Chart.yaml @@ -0,0 +1,30 @@ +apiVersion: v2 +name: nexus-gateway +description: Nexus Gateway — enterprise AI traffic gateway (console, hub, AI gateway, compliance proxy) for Kubernetes. +type: application +version: 0.1.0 +# Tracks the app/image release line; override images.tag to pin a published tag. +appVersion: "1.1.0" +home: https://github.com/AlphaBitCore/nexus-gateway +sources: + - https://github.com/AlphaBitCore/nexus-gateway +maintainers: + - name: AlphaBitCore + +# Backing stores are optional subchart dependencies — enabled by default for a +# batteries-included install, disabled when you point at external managed +# Postgres / Valkey / NATS via values. Run `helm dependency build` before +# install to fetch them (see deploy/helm/nexus-gateway/README.md). +dependencies: + - name: postgresql + version: "15.x.x" + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled + - name: valkey + version: "2.x.x" + repository: https://charts.bitnami.com/bitnami + condition: valkey.enabled + - name: nats + version: "1.x.x" + repository: https://nats-io.github.io/k8s/helm/charts/ + condition: nats.enabled diff --git a/deploy/helm/nexus-gateway/README.md b/deploy/helm/nexus-gateway/README.md new file mode 100644 index 00000000..bde0d8dc --- /dev/null +++ b/deploy/helm/nexus-gateway/README.md @@ -0,0 +1,78 @@ +# nexus-gateway Helm chart + +Production Kubernetes deploy for Nexus Gateway. Installs the four service +Deployments — **console** (nginx + control-plane + UI), **hub**, **ai-gateway**, +**compliance-proxy** — with backing Postgres / Valkey / NATS either in-cluster +(default subcharts, for dev/demo) or external (managed, for production). + +> docker-compose (`docker-compose.full.yml`) is the quick dev/demo path; this +> chart is the production path. Design + secret contract: +> [`container-deployment-architecture.md`](../../../docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md). + +## Prerequisites + +- Kubernetes 1.26+, Helm 3.12+ +- An ingress controller (nginx-ingress assumed by `values-production.yaml`) +- The published images on Docker Hub (`docker.io/alphabitcore/nexus-*`) or your + own registry mirror set via `images.registry` + +## Install (dev / demo — in-cluster datastores) + +```bash +cd deploy/helm/nexus-gateway +helm dependency build # fetch postgresql / valkey / nats subcharts +helm install nexus . \ + --namespace nexus --create-namespace +kubectl -n nexus wait --for=condition=ready pod --all --timeout=600s +``` + +The pre-install hook Job (`nexus-db-migrate`) pushes the schema and runs the +production seed before the services start. Shared secrets are generated once +into `-nexus-gateway-secrets` and persist across upgrades (they are +never rotated underneath encrypted rows / issued keys). + +## Install (production — external datastores, real TLS + secrets) + +Provide secrets out-of-band (sealed-secrets / external-secrets / vault) as a +Secret with keys `internalServiceToken`, `adminKeyHmacSecret`, +`credentialEncryptionKey`, `complianceProxyApiToken`, `aiGatewayApiToken`, +then: + +```bash +helm install nexus . \ + --namespace nexus --create-namespace \ + -f values-production.yaml \ + --set images.tag=v1.1.0 \ + --set secrets.existingSecret=nexus-shared-secrets \ + --set consoleTls.existingSecret=nexus-console-tls \ + --set complianceProxy.caExistingSecret=nexus-proxy-ca \ + --set external.databaseUrl='postgresql://…' \ + --set external.redisAddr='…:6379' \ + --set external.natsUrl='nats://…:4222' +``` + +## Key values + +| Key | Default | Notes | +|---|---|---| +| `images.registry` / `images.tag` | `docker.io/alphabitcore` / `latest` | pin a semver in prod | +| `aiGateway.replicas` | `2` | the load-bearing tier — scale to traffic | +| `complianceProxy.enabled` | `true` | set `false` if you don't intercept egress TLS | +| `dbInit.enabled` / `dbInit.seedDemo` | `true` / `false` | pre-install schema+seed hook | +| `secrets.existingSecret` | `""` | supply your own Secret; else auto-generated once | +| `postgresql/valkey/nats.enabled` | `true` | in-cluster subcharts; disable + set `external.*` for managed | +| `ingress.host` | `nexus.local` | must match `authServer.issuer` | + +## Verify + +```bash +helm lint . +helm template nexus . | kubectl apply --dry-run=client -f - +``` + +`compliance-proxy` and `ai-gateway` link Vectorscan statically; verify the +engine in a running pod: + +```bash +kubectl -n nexus exec deploy/nexus-nexus-gateway-ai-gateway -- hs-selfcheck +``` diff --git a/deploy/helm/nexus-gateway/templates/_helpers.tpl b/deploy/helm/nexus-gateway/templates/_helpers.tpl new file mode 100644 index 00000000..297f1965 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/_helpers.tpl @@ -0,0 +1,83 @@ +{{/* Common labels + name helpers. */}} +{{- define "nexus.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "nexus.fullname" -}} +{{- printf "%s-%s" .Release.Name (include "nexus.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "nexus.labels" -}} +app.kubernetes.io/name: {{ include "nexus.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +{{- end -}} + +{{/* Image reference for a service: /:. */}} +{{- define "nexus.image" -}} +{{- printf "%s/%s:%s" .root.Values.images.registry .repo (.root.Values.images.tag | toString) -}} +{{- end -}} + +{{/* The Secret holding shared secrets — either the user's or ours. */}} +{{- define "nexus.secretName" -}} +{{- if .Values.secrets.existingSecret -}} +{{- .Values.secrets.existingSecret -}} +{{- else -}} +{{- printf "%s-secrets" (include "nexus.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{/* DATABASE_URL: external override wins, else the in-cluster postgresql subchart. */}} +{{- define "nexus.databaseUrl" -}} +{{- if .Values.external.databaseUrl -}} +{{- .Values.external.databaseUrl -}} +{{- else -}} +{{- printf "postgresql://%s@%s-postgresql:5432/%s" .Values.postgresql.auth.username .Release.Name .Values.postgresql.auth.database -}} +{{- end -}} +{{- end -}} + +{{- define "nexus.redisAddr" -}} +{{- if .Values.external.redisAddr -}} +{{- .Values.external.redisAddr -}} +{{- else -}} +{{- printf "%s-valkey-master:6379" .Release.Name -}} +{{- end -}} +{{- end -}} + +{{- define "nexus.natsUrl" -}} +{{- if .Values.external.natsUrl -}} +{{- .Values.external.natsUrl -}} +{{- else -}} +{{- printf "nats://%s-nats:4222" .Release.Name -}} +{{- end -}} +{{- end -}} + +{{/* +Resolve one shared-secret value to base64: explicit user value wins, else the +value already stored in the release's Secret (persist across upgrade), else a +fresh random. Arg: dict (given, existingB64, len). +*/}} +{{- define "nexus.secretVal" -}} +{{- if .given }}{{ .given | b64enc }} +{{- else if .existingB64 }}{{ .existingB64 }} +{{- else }}{{ randAlphaNum .len | b64enc }}{{- end }} +{{- end -}} + +{{/* Shared env block every service needs: infra addrs + secret refs. */}} +{{- define "nexus.commonEnv" -}} +- name: DATABASE_URL + value: {{ include "nexus.databaseUrl" . | quote }} +- name: NEXUS_REDIS_ADDR + value: {{ include "nexus.redisAddr" . | quote }} +- name: NEXUS_NATS_URL + value: {{ include "nexus.natsUrl" . | quote }} +- name: NEXUS_HUB_URL + value: {{ printf "http://%s-hub:3060" (include "nexus.fullname" .) | quote }} +- name: INTERNAL_SERVICE_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: internalServiceToken} +- name: CREDENTIAL_ENCRYPTION_KEY + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: credentialEncryptionKey} +{{- end -}} diff --git a/deploy/helm/nexus-gateway/templates/ai-gateway.yaml b/deploy/helm/nexus-gateway/templates/ai-gateway.yaml new file mode 100644 index 00000000..061ab563 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/ai-gateway.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nexus.fullname" . }}-ai-gateway + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: ai-gateway +spec: + replicas: {{ .Values.aiGateway.replicas }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: ai-gateway + template: + metadata: + labels: + {{- include "nexus.labels" . | nindent 8 }} + app.kubernetes.io/component: ai-gateway + spec: + {{- with .Values.images.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: ai-gateway + image: {{ include "nexus.image" (dict "root" . "repo" "nexus-ai-gateway") | quote }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + {{- include "nexus.commonEnv" . | nindent 12 }} + - name: NEXUS_AI_GATEWAY_PUBLIC_URL + value: {{ .Values.authServer.issuer | quote }} + - name: ADMIN_KEY_HMAC_SECRET + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: adminKeyHmacSecret} + - name: AI_GATEWAY_API_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: aiGatewayApiToken} + ports: + - {name: http, containerPort: 3050} + readinessProbe: + httpGet: {path: /healthz, port: 3050} + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: 3050} + initialDelaySeconds: 10 + periodSeconds: 10 + resources: {{ toYaml .Values.aiGateway.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nexus.fullname" . }}-ai-gateway + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: ai-gateway +spec: + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: ai-gateway + ports: + - {name: http, port: 3050, targetPort: 3050} diff --git a/deploy/helm/nexus-gateway/templates/compliance-proxy.yaml b/deploy/helm/nexus-gateway/templates/compliance-proxy.yaml new file mode 100644 index 00000000..5290e60d --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/compliance-proxy.yaml @@ -0,0 +1,75 @@ +{{- if .Values.complianceProxy.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nexus.fullname" . }}-compliance-proxy + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: compliance-proxy +spec: + replicas: {{ .Values.complianceProxy.replicas }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: compliance-proxy + template: + metadata: + labels: + {{- include "nexus.labels" . | nindent 8 }} + app.kubernetes.io/component: compliance-proxy + spec: + {{- with .Values.images.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: compliance-proxy + image: {{ include "nexus.image" (dict "root" . "repo" "nexus-compliance-proxy") | quote }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + {{- include "nexus.commonEnv" . | nindent 12 }} + - name: NEXUS_COMPLIANCE_PROXY_PUBLIC_URL + value: {{ printf "http://%s-compliance-proxy:3128" (include "nexus.fullname" .) | quote }} + - name: COMPLIANCE_PROXY_PUBLIC_URL + value: {{ printf "http://%s-compliance-proxy:3128" (include "nexus.fullname" .) | quote }} + - name: COMPLIANCE_PROXY_API_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: complianceProxyApiToken} + ports: + - {name: proxy, containerPort: 3128} + - {name: runtime, containerPort: 3040} + - {name: metrics, containerPort: 9090} + {{- if .Values.complianceProxy.caExistingSecret }} + volumeMounts: + - {name: ca, mountPath: /etc/compliance-proxy, readOnly: true} + {{- end }} + readinessProbe: + httpGet: {path: /metrics, port: 9090} + initialDelaySeconds: 5 + periodSeconds: 5 + resources: {{ toYaml .Values.complianceProxy.resources | nindent 12 }} + {{- if .Values.complianceProxy.caExistingSecret }} + volumes: + - name: ca + secret: + secretName: {{ .Values.complianceProxy.caExistingSecret }} + items: + - {key: ca.crt, path: ca.crt} + - {key: ca.key, path: ca.key} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nexus.fullname" . }}-compliance-proxy + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: compliance-proxy +spec: + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: compliance-proxy + ports: + - {name: proxy, port: 3128, targetPort: 3128} + - {name: runtime, port: 3040, targetPort: 3040} + - {name: metrics, port: 9090, targetPort: 9090} +{{- end }} diff --git a/deploy/helm/nexus-gateway/templates/console.yaml b/deploy/helm/nexus-gateway/templates/console.yaml new file mode 100644 index 00000000..bd0a7392 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/console.yaml @@ -0,0 +1,93 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nexus.fullname" . }}-console + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: console +spec: + replicas: {{ .Values.console.replicas }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: console + template: + metadata: + labels: + {{- include "nexus.labels" . | nindent 8 }} + app.kubernetes.io/component: console + spec: + {{- with .Values.images.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: console + image: {{ include "nexus.image" (dict "root" . "repo" "nexus-console") | quote }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + {{- include "nexus.commonEnv" . | nindent 12 }} + - name: NEXUS_AI_GATEWAY_URL + value: {{ printf "http://%s-ai-gateway:3050" (include "nexus.fullname" .) | quote }} + - name: NEXUS_AI_GATEWAY_UPSTREAM + value: {{ printf "http://%s-ai-gateway:3050" (include "nexus.fullname" .) | quote }} + - name: NEXUS_HUB_UPSTREAM + value: {{ printf "http://%s-hub:3060" (include "nexus.fullname" .) | quote }} + {{- if .Values.complianceProxy.enabled }} + - name: NEXUS_COMPLIANCE_PROXY_URL + value: {{ printf "http://%s-compliance-proxy:3040" (include "nexus.fullname" .) | quote }} + {{- end }} + - name: CONTROL_PLANE_PUBLIC_URL + value: {{ .Values.authServer.issuer | quote }} + - name: ADMIN_KEY_HMAC_SECRET + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: adminKeyHmacSecret} + - name: HUB_CONFIG_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: hubConfigToken} + - name: COMPLIANCE_PROXY_API_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: complianceProxyApiToken} + - name: AUTH_SERVER_ISSUER + value: {{ .Values.authServer.issuer | quote }} + ports: + - {name: https, containerPort: 443} + - {name: http, containerPort: 80} + {{- if .Values.consoleTls.existingSecret }} + volumeMounts: + - {name: tls, mountPath: /etc/nexus, readOnly: false} + {{- end }} + readinessProbe: + httpGet: {path: /healthz, port: 443, scheme: HTTPS} + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: 443, scheme: HTTPS} + initialDelaySeconds: 20 + periodSeconds: 10 + resources: {{ toYaml .Values.console.resources | nindent 12 }} + {{- if .Values.consoleTls.existingSecret }} + volumes: + - name: tls + secret: + secretName: {{ .Values.consoleTls.existingSecret }} + items: + - {key: tls.crt, path: tls.crt} + - {key: tls.key, path: tls.key} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nexus.fullname" . }}-console + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: console +spec: + type: {{ .Values.console.service.type }} + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: console + ports: + - {name: https, port: {{ .Values.console.service.httpsPort }}, targetPort: 443} + - {name: http, port: 80, targetPort: 80} + - {name: cp, port: 3001, targetPort: 3001} diff --git a/deploy/helm/nexus-gateway/templates/hub.yaml b/deploy/helm/nexus-gateway/templates/hub.yaml new file mode 100644 index 00000000..2e9d8278 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/hub.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nexus.fullname" . }}-hub + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: hub +spec: + replicas: {{ .Values.hub.replicas }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: hub + template: + metadata: + labels: + {{- include "nexus.labels" . | nindent 8 }} + app.kubernetes.io/component: hub + spec: + {{- with .Values.images.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: hub + image: {{ include "nexus.image" (dict "root" . "repo" "nexus-hub") | quote }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + {{- include "nexus.commonEnv" . | nindent 12 }} + - name: NEXUS_CONSOLE_INTERNAL_URL + value: {{ printf "http://%s-console:3001" (include "nexus.fullname" .) | quote }} + - name: NEXUS_AUTH_ISSUER + value: {{ .Values.authServer.issuer | quote }} + - name: NEXUS_HUB_ADVERTISE_ADDR + value: {{ printf "http://%s-hub:3060" (include "nexus.fullname" .) | quote }} + - name: NEXUS_HUB_PUBLIC_URL + value: {{ printf "http://%s-hub:3060" (include "nexus.fullname" .) | quote }} + - name: HUB_CONFIG_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: hubConfigToken} + ports: + - {name: http, containerPort: 3060} + readinessProbe: + httpGet: {path: /readyz, port: 3060} + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: 3060} + initialDelaySeconds: 10 + periodSeconds: 10 + resources: {{ toYaml .Values.hub.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nexus.fullname" . }}-hub + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: hub +spec: + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: hub + ports: + - {name: http, port: 3060, targetPort: 3060} diff --git a/deploy/helm/nexus-gateway/templates/ingress.yaml b/deploy/helm/nexus-gateway/templates/ingress.yaml new file mode 100644 index 00000000..7d339608 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/ingress.yaml @@ -0,0 +1,34 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "nexus.fullname" . }} + labels: + {{- include "nexus.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: {{ toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: [{{ .Values.ingress.host | quote }}] + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + # Everything terminates at the console, which reverse-proxies the + # gateway (/v1, /v1beta, ...) and hub (/ws, enrollment) internally — + # a single external surface, matching the AMI's nginx. + - path: / + pathType: Prefix + backend: + service: + name: {{ include "nexus.fullname" . }}-console + port: + number: {{ .Values.console.service.httpsPort }} +{{- end }} diff --git a/deploy/helm/nexus-gateway/templates/jobs/db-init.yaml b/deploy/helm/nexus-gateway/templates/jobs/db-init.yaml new file mode 100644 index 00000000..41003f82 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/jobs/db-init.yaml @@ -0,0 +1,50 @@ +{{- if .Values.dbInit.enabled }} +# Schema + seed as a pre-install/pre-upgrade hook, mirroring the AMI +# first-boot-db sequence (prisma db push --accept-data-loss → prod seed). Runs +# before any service Deployment so the DB is materialised on first install and +# migrated on upgrade. The seed re-encrypts credential rows / re-hashes VK +# lookup keys, so it needs the same CREDENTIAL_ENCRYPTION_KEY + +# ADMIN_KEY_HMAC_SECRET the services use. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "nexus.fullname" . }}-db-init + labels: + {{- include "nexus.labels" . | nindent 4 }} + app.kubernetes.io/component: db-init + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "0" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + template: + metadata: + labels: + {{- include "nexus.labels" . | nindent 8 }} + app.kubernetes.io/component: db-init + spec: + restartPolicy: Never + {{- with .Values.images.pullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: db-init + image: {{ .Values.dbInit.image | quote }} + imagePullPolicy: {{ .Values.images.pullPolicy }} + env: + - name: DATABASE_URL + value: {{ include "nexus.databaseUrl" . | quote }} + - name: SEED_DEMO + value: {{ .Values.dbInit.seedDemo | quote }} + - name: CREDENTIAL_ENCRYPTION_KEY + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: credentialEncryptionKey} + - name: ADMIN_KEY_HMAC_SECRET + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: adminKeyHmacSecret} + - name: INTERNAL_SERVICE_TOKEN + valueFrom: + secretKeyRef: {name: {{ include "nexus.secretName" . }}, key: internalServiceToken} + command: ["sh", "-c", "npm run db:push -- --accept-data-loss && npm run seed:prod"] +{{- end }} diff --git a/deploy/helm/nexus-gateway/templates/secret.yaml b/deploy/helm/nexus-gateway/templates/secret.yaml new file mode 100644 index 00000000..e14e9230 --- /dev/null +++ b/deploy/helm/nexus-gateway/templates/secret.yaml @@ -0,0 +1,27 @@ +{{/* +Shared-secret Secret. Only rendered when the user did NOT supply an +existingSecret. Values are taken from .Values.secrets when set; blanks are +filled with generated randoms that PERSIST across upgrades via lookup (so we +never rotate CREDENTIAL_ENCRYPTION_KEY / ADMIN_KEY_HMAC_SECRET underneath +already-encrypted rows or issued keys). +*/}} +{{- if not .Values.secrets.existingSecret }} +{{- $name := printf "%s-secrets" (include "nexus.fullname" .) }} +{{- $existing := (lookup "v1" "Secret" .Release.Namespace $name) }} +{{- $data := dict }} +{{- if $existing }}{{ $data = $existing.data }}{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "nexus.labels" . | nindent 4 }} +type: Opaque +data: + internalServiceToken: {{ include "nexus.secretVal" (dict "given" .Values.secrets.internalServiceToken "existingB64" (get $data "internalServiceToken") "len" 64) }} + hubConfigToken: {{ include "nexus.secretVal" (dict "given" .Values.secrets.hubConfigToken "existingB64" (get $data "hubConfigToken") "len" 64) }} + adminKeyHmacSecret: {{ include "nexus.secretVal" (dict "given" .Values.secrets.adminKeyHmacSecret "existingB64" (get $data "adminKeyHmacSecret") "len" 64) }} + credentialEncryptionKey: {{ include "nexus.secretVal" (dict "given" .Values.secrets.credentialEncryptionKey "existingB64" (get $data "credentialEncryptionKey") "len" 64) }} + complianceProxyApiToken: {{ include "nexus.secretVal" (dict "given" .Values.secrets.complianceProxyApiToken "existingB64" (get $data "complianceProxyApiToken") "len" 64) }} + aiGatewayApiToken: {{ include "nexus.secretVal" (dict "given" .Values.secrets.aiGatewayApiToken "existingB64" (get $data "aiGatewayApiToken") "len" 64) }} +{{- end }} diff --git a/deploy/helm/nexus-gateway/values-production.yaml b/deploy/helm/nexus-gateway/values-production.yaml new file mode 100644 index 00000000..ffad1f18 --- /dev/null +++ b/deploy/helm/nexus-gateway/values-production.yaml @@ -0,0 +1,62 @@ +# Production overrides for the nexus-gateway chart. +# helm install nexus deploy/helm/nexus-gateway -f deploy/helm/nexus-gateway/values-production.yaml +# +# Assumes managed external Postgres / Valkey / NATS and real TLS + secrets. + +images: + tag: "" # PIN a published semver, e.g. "v1.1.0" — never latest in prod + pullPolicy: IfNotPresent + +# Provide the shared secrets out-of-band (sealed-secret / external-secrets / +# vault) and reference the resulting Secret here — never inline in a values +# file committed to git. +secrets: + existingSecret: "nexus-shared-secrets" + +# Real cert for the console (the OIDC issuer host). +consoleTls: + existingSecret: "nexus-console-tls" + +# Real MITM CA for the compliance proxy. +complianceProxy: + enabled: true + replicas: 2 + caExistingSecret: "nexus-proxy-ca" + +console: + replicas: 2 + service: + type: ClusterIP + +hub: + replicas: 2 + +aiGateway: + replicas: 4 # the load-bearing tier — scale to traffic + +ingress: + enabled: true + className: nginx + host: nexus.example.com + tls: + enabled: true + secretName: nexus-console-tls + annotations: + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + nginx.ingress.kubernetes.io/proxy-body-size: "32m" + +authServer: + issuer: "https://nexus.example.com" + +# Managed datastores — disable the in-cluster subcharts, point at real ones. +postgresql: + enabled: false +valkey: + enabled: false +nats: + enabled: false + +external: + databaseUrl: "" # postgresql://user:pass@rds-host:5432/nexus_gateway (via secret-injected env in real setups) + redisAddr: "" # elasticache-host:6379 + natsUrl: "" # nats://nats-host:4222 diff --git a/deploy/helm/nexus-gateway/values.yaml b/deploy/helm/nexus-gateway/values.yaml new file mode 100644 index 00000000..9518355a --- /dev/null +++ b/deploy/helm/nexus-gateway/values.yaml @@ -0,0 +1,109 @@ +# Default values for the nexus-gateway chart (dev / demo shape). +# Production overrides live in values-production.yaml. + +images: + registry: docker.io/alphabitcore + tag: latest + pullPolicy: IfNotPresent + pullSecrets: [] + +# Shared secrets — every service must agree on these ([MUST MATCH] in +# .env.example). Leave blank to have the pre-install hook generate them into a +# Secret once; set them explicitly (or point existingSecret at your own) for +# GitOps / reproducible installs. NEVER commit real values to a values file. +secrets: + existingSecret: "" # if set, all secret values are read from it + internalServiceToken: "" + hubConfigToken: "" # [MUST MATCH] Hub <-> Control Plane + adminKeyHmacSecret: "" + credentialEncryptionKey: "" # 64 hex chars + complianceProxyApiToken: "" + aiGatewayApiToken: "" + +# db-init runs prisma db push + prod seed as a pre-install/pre-upgrade hook Job +# (mirrors the AMI first-boot-db sequence). Uses the node:20 image + the +# tools/db-migrate assets baked into a small init image. +dbInit: + enabled: true + image: docker.io/alphabitcore/nexus-db-migrate:latest + seedDemo: false + +console: + replicas: 1 + service: + type: ClusterIP + httpsPort: 443 + resources: + requests: {cpu: "100m", memory: "128Mi"} + limits: {cpu: "1", memory: "512Mi"} + +hub: + replicas: 1 + resources: + requests: {cpu: "100m", memory: "128Mi"} + limits: {cpu: "1", memory: "512Mi"} + +aiGateway: + # The load-bearing service — scale this independently. + replicas: 2 + resources: + requests: {cpu: "250m", memory: "256Mi"} + limits: {cpu: "2", memory: "1Gi"} + +complianceProxy: + # Optional layer: set enabled=false for deployments that don't intercept + # egress TLS, and the image is simply never scheduled. + enabled: true + replicas: 1 + # Mount a real MITM CA here for production; a self-signed one is generated + # into a Secret by the pre-install hook if left empty. + caExistingSecret: "" + resources: + requests: {cpu: "250m", memory: "256Mi"} + limits: {cpu: "2", memory: "1Gi"} + +ingress: + enabled: true + className: "" + host: nexus.local + tls: + enabled: false + secretName: "" + annotations: {} + +# TLS the console terminates. For production set console.tls.existingSecret to +# a real cert; otherwise the pre-install hook seeds a self-signed pair. +consoleTls: + existingSecret: "" + +authServer: + # Must match the URL clients reach the console on (the OIDC issuer claim). + issuer: "https://nexus.local" + +# ── Backing stores ──────────────────────────────────────────────────────── +# Enabled = in-cluster subcharts (dev/demo). For production, disable these and +# fill external.* with managed endpoints. +postgresql: + enabled: true + auth: + username: nexus + database: nexus_gateway + # password left to the subchart's generated secret; wired via the chart. + +valkey: + enabled: true + architecture: standalone + auth: + enabled: false + +nats: + enabled: true + config: + jetstream: + enabled: true + +external: + # Used only when the matching subchart is disabled. + databaseUrl: "" # postgresql://user:pass@host:5432/nexus_gateway + redisAddr: "" # host:6379 + natsUrl: "" # nats://host:4222 diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 00000000..7cb89a96 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,198 @@ +# Nexus Gateway — full-stack docker-compose (dev / demo quick setup). +# +# ./scripts/compose-init.sh # once: generate secrets +# docker compose -f docker-compose.full.yml \ +# --env-file deploy/docker/.env.compose up -d # bring the stack up +# open https://localhost/ # console (self-signed TLS) +# +# Production deploys use the Helm chart (deploy/helm/nexus-gateway), not this +# file. Architecture: docs/developers/architecture/cross-cutting/deployment/ +# container-deployment-architecture.md +# +# Secrets live ONLY in deploy/docker/.env.compose (generated, gitignored) — +# never in this yaml. The dev-infra docker-compose.yml (postgres/valkey/nats +# for `scripts/dev-start.sh`) is untouched by this file; the two use separate +# container names and volumes so they can coexist. + +name: nexus-full + +services: + postgres: + image: postgres:16-alpine + container_name: nexus-full-postgres + environment: + POSTGRES_DB: nexus_gateway + POSTGRES_USER: nexus + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?run scripts/compose-init.sh first} + volumes: + - nexus-full-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nexus -d nexus_gateway"] + interval: 5s + timeout: 3s + retries: 12 + + valkey: + # Same image the dev-infra compose uses: BSD-licensed, wire-compatible + # with Redis 7, bundles valkey-search. + image: valkey/valkey-bundle:8-trixie + container_name: nexus-full-valkey + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + nats: + image: nats:2-alpine + container_name: nexus-full-nats + command: ["-js", "-m", "8222"] + volumes: + - nexus-full-natsdata:/data + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8222/healthz"] + interval: 5s + timeout: 3s + retries: 12 + + # One-shot schema + seed, mirroring the AMI first-boot-db sequence + # (prisma db push --accept-data-loss → prisma generate → prod seed). The + # seed re-encrypts credential rows and re-hashes VK lookup keys, so it needs + # the same CREDENTIAL_ENCRYPTION_KEY / ADMIN_KEY_HMAC_SECRET the services use. + db-init: + image: ${NEXUS_IMAGE_REGISTRY:-alphabitcore}/nexus-db-migrate:${NEXUS_IMAGE_TAG:-latest} + container_name: nexus-full-db-init + environment: + DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD}@postgres:5432/nexus_gateway + SEED_DEMO: "false" + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY} + ADMIN_KEY_HMAC_SECRET: ${ADMIN_KEY_HMAC_SECRET} + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + COMPLIANCE_PROXY_API_TOKEN: ${COMPLIANCE_PROXY_API_TOKEN} + # Image default CMD runs prisma db push --accept-data-loss + prod seed. + depends_on: + postgres: + condition: service_healthy + restart: "no" + + nexus-hub: + image: ${NEXUS_IMAGE_REGISTRY:-alphabitcore}/nexus-hub:${NEXUS_IMAGE_TAG:-latest} + container_name: nexus-full-hub + environment: + DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD}@postgres:5432/nexus_gateway + NEXUS_REDIS_ADDR: valkey:6379 + NEXUS_NATS_URL: nats://nats:4222 + NEXUS_CONSOLE_INTERNAL_URL: http://nexus-console:3001 + NEXUS_HUB_PUBLIC_URL: http://nexus-hub:3060 + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + HUB_CONFIG_TOKEN: ${HUB_CONFIG_TOKEN} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY} + volumes: + - nexus-full-agentca:/var/lib/nexus/agentca + depends_on: + db-init: + condition: service_completed_successfully + valkey: + condition: service_healthy + nats: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3060/healthz > /dev/null"] + interval: 5s + timeout: 3s + retries: 24 + + nexus-ai-gateway: + image: ${NEXUS_IMAGE_REGISTRY:-alphabitcore}/nexus-ai-gateway:${NEXUS_IMAGE_TAG:-latest} + container_name: nexus-full-ai-gateway + ports: + - "3050:3050" # ingress for API clients (OpenAI/Anthropic/Gemini wire formats) + environment: + DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD}@postgres:5432/nexus_gateway + NEXUS_REDIS_ADDR: valkey:6379 + NEXUS_NATS_URL: nats://nats:4222 + NEXUS_HUB_URL: http://nexus-hub:3060 + NEXUS_AI_GATEWAY_PUBLIC_URL: http://localhost:3050 + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + ADMIN_KEY_HMAC_SECRET: ${ADMIN_KEY_HMAC_SECRET} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY} + AI_GATEWAY_API_TOKEN: ${AI_GATEWAY_API_TOKEN} + depends_on: + nexus-hub: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3050/healthz > /dev/null"] + interval: 5s + timeout: 3s + retries: 24 + + nexus-compliance-proxy: + image: ${NEXUS_IMAGE_REGISTRY:-alphabitcore}/nexus-compliance-proxy:${NEXUS_IMAGE_TAG:-latest} + container_name: nexus-full-compliance-proxy + ports: + - "3128:3128" # CONNECT proxy for intercepted clients + environment: + DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD}@postgres:5432/nexus_gateway + NEXUS_REDIS_ADDR: valkey:6379 + NEXUS_NATS_URL: nats://nats:4222 + NEXUS_HUB_URL: http://nexus-hub:3060 + NEXUS_COMPLIANCE_PROXY_PUBLIC_URL: http://localhost:3128 + COMPLIANCE_PROXY_PUBLIC_URL: http://localhost:3128 + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + COMPLIANCE_PROXY_API_TOKEN: ${COMPLIANCE_PROXY_API_TOKEN} + volumes: + - nexus-full-proxyca:/etc/compliance-proxy + - nexus-full-proxyspool:/var/lib/nexus + depends_on: + nexus-hub: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9090/metrics > /dev/null"] + interval: 5s + timeout: 3s + retries: 24 + + nexus-console: + image: ${NEXUS_IMAGE_REGISTRY:-alphabitcore}/nexus-console:${NEXUS_IMAGE_TAG:-latest} + container_name: nexus-full-console + ports: + - "80:80" # http → https redirect + - "443:443" # console UI + admin API + gateway/hub reverse proxy + environment: + DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD}@postgres:5432/nexus_gateway + NEXUS_REDIS_ADDR: valkey:6379 + NEXUS_NATS_URL: nats://nats:4222 + NEXUS_HUB_URL: http://nexus-hub:3060 + NEXUS_AI_GATEWAY_URL: http://nexus-ai-gateway:3050 + NEXUS_AI_GATEWAY_UPSTREAM: http://nexus-ai-gateway:3050 + NEXUS_HUB_UPSTREAM: http://nexus-hub:3060 + NEXUS_COMPLIANCE_PROXY_URL: http://nexus-compliance-proxy:3040 + CONTROL_PLANE_PUBLIC_URL: https://localhost + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + HUB_CONFIG_TOKEN: ${HUB_CONFIG_TOKEN} + ADMIN_KEY_HMAC_SECRET: ${ADMIN_KEY_HMAC_SECRET} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY} + COMPLIANCE_PROXY_API_TOKEN: ${COMPLIANCE_PROXY_API_TOKEN} + AUTH_SERVER_ISSUER: ${AUTH_SERVER_ISSUER:-https://localhost} + volumes: + - nexus-full-consoleca:/var/lib/nexus + - nexus-full-consoletls:/etc/nexus + depends_on: + nexus-hub: + condition: service_healthy + nexus-ai-gateway: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --no-check-certificate -O /dev/null https://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 24 + +volumes: + nexus-full-pgdata: + nexus-full-natsdata: + nexus-full-agentca: + nexus-full-proxyca: + nexus-full-proxyspool: + nexus-full-consoleca: + nexus-full-consoletls: diff --git a/docs/developers/architecture/README.md b/docs/developers/architecture/README.md index 49e15cec..3a8cd42c 100644 --- a/docs/developers/architecture/README.md +++ b/docs/developers/architecture/README.md @@ -173,6 +173,7 @@ If you are about to edit code in an area that is genuinely **not** covered by an | Editing area / file glob | Read FIRST | |---|---| | `nexus-ami/**` — Packer template, install / first-boot / harden scripts, prod-shape `*.config.yaml`, systemd unit files for the AMI / bare-metal appliance form factor | `docs/developers/architecture/cross-cutting/deployment/ami-appliance-architecture.md` | +| `packages/*/Dockerfile`, `deploy/docker/**`, `deploy/helm/**`, `docker-compose.full.yml`, `scripts/compose-init.sh`, `.github/workflows/docker-publish.yml`, `tools/db-migrate/Dockerfile` — container images (4 product + db-migrate utility), docker-compose (dev/demo), Helm chart (production), publish pipeline + Vectorscan build discipline | `docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md` | ## Adding a new arch doc diff --git a/docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md b/docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md new file mode 100644 index 00000000..d9336bc5 --- /dev/null +++ b/docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md @@ -0,0 +1,137 @@ +# Container deployment architecture + +How Nexus Gateway ships as container images and deploys via docker-compose +(dev / demo) and Helm (production). Sibling to the appliance form factor in +[`ami-appliance-architecture.md`](./ami-appliance-architecture.md); the two +share the same binaries, config-env contract, and shared-secret set — they +differ only in packaging and orchestration. + +## Images + +Four **product images** are built and published to Docker Hub under the +`alphabitcore` org. Backing stores (PostgreSQL, Valkey, NATS; ClickHouse in a +later phase) are pulled from upstream images, never rebuilt. + +| Image | Contents | Ports | Build | +|---|---|---|---| +| `alphabitcore/nexus-console` | nginx + control-plane binary + control-plane-UI dist | 80, 443, 3001 | pure Go + static assets (`CGO_ENABLED=0`) | +| `alphabitcore/nexus-hub` | hub binary | 3060 | pure Go | +| `alphabitcore/nexus-ai-gateway` | ai-gateway binary | 3050 | ⚠ cgo + static libhs (Vectorscan) | +| `alphabitcore/nexus-compliance-proxy` | compliance-proxy binary | 3128, 3040, 9090 | ⚠ cgo + static libhs (Vectorscan) | + +Plus one **deploy-utility image**, `alphabitcore/nexus-db-migrate` (not a +product image): wraps `tools/db-migrate` so schema push + prod seed can run as +a Helm hook Job. docker-compose uses the same image for its `db-init` service. + +**Why split rather than one monolith:** compliance-proxy is optional per +customer — a separate image means deployments that don't intercept egress TLS +simply don't pull it. hub + ai-gateway are the load-bearing pair and scale +independently; console and compliance-proxy stay light and independently +versioned. + +### The console image (composition) + +`deploy/docker/console/Dockerfile` is a three-stage build: Go builder +(control-plane) + Node builder (UI dist) + `nginx:alpine` final. The entrypoint +renders `nginx.conf.tpl` and `config.yaml.tpl` with `envsubst`, ensures TLS +material exists (mount real certs at `/etc/nexus/tls.{crt,key}` for production; +a self-signed pair is generated otherwise), starts the control-plane on +loopback `:3001`, waits for its `/healthz`, then runs nginx in the foreground. +Both processes are supervised: if either exits, the container exits and the +orchestrator restarts it atomically. + +The nginx config is derived from `nexus-ami/artifacts/configs/nginx-nexus.conf` +and keeps the same location blocks (SPA fallback, `/api/`+auth → control-plane, +`/v1|/v1beta|/openai/deployments|/api/paas` → ai-gateway, `/ws`+enrollment → +hub). The only change: the AI-gateway and hub upstreams are **env-parameterized +service names** (`NEXUS_AI_GATEWAY_UPSTREAM`, `NEXUS_HUB_UPSTREAM`) instead of +`127.0.0.1`, so one image works under compose service DNS and K8s cluster DNS. +Keep the two nginx files' location blocks in lockstep. + +## Vectorscan build discipline (ai-gateway + compliance-proxy) + +Both services link libhs (Vectorscan) via cgo for accelerated content +scanning; the pure-Go RE2 matcher is the differential oracle and the fallback. +The Matcher seam selects the engine by build tag: `-tags vectorscan` compiles +`packages/shared/policy/hooks/matcher/vectorscan.go` (cgo); the default build +compiles `compile_default_re2.go`. **A no-tag image silently uses RE2** — +correct but far slower under load. The Dockerfiles enforce four rules so a +fallback image can never ship by accident: + +1. **Per-arch static libhs.** Each Dockerfile compiles libhs from source in a + builder stage; buildx builds one matching static archive per target arch. +2. **`FAT_RUNTIME=OFF` (mandatory).** With it ON the library links fine but + `hs_alloc_scratch` silently fails inside a cgo binary and scanning never + fires — PII passes through unredacted with no error. `BUILD_AVX512=OFF` for + portability (an AVX512 build SIGILLs on CPUs without avx512vbmi). +3. **Build-time self-test (Gate 1).** A tiny program calls + `matcher.HSSelfTest()` (`hs_compile` + `hs_alloc_scratch` + `hs_scan`); + a non-zero return or zero matches fails the build. The `hs-selfcheck` binary + ships in the image so CI and operators can re-run it in the exact runtime: + `docker run --rm --entrypoint hs-selfcheck `. +4. **Binary tag check (Gate 2).** `go version -m` must show `-tags=vectorscan` + on the shipped binary, else the build fails. + +Arch scope: `linux/amd64` first; `linux/arm64` is an additive fast-follow +(per-arch libhs makes it structural-free). Runtime base is `debian:bookworm-slim` +(glibc) rather than alpine/musl to avoid cgo linking friction. + +## docker-compose (dev / demo) + +`docker-compose.full.yml` brings up the whole stack: postgres + valkey + nats +(upstream images) + `db-init` (one-shot, `service_completed_successfully` gate) ++ hub → ai-gateway → compliance-proxy → console, ordered by healthchecks and +`depends_on: {condition: service_healthy}` mirroring the AMI systemd order. It +coexists with the dev-infra `docker-compose.yml` (used by `scripts/dev-start.sh`) +via distinct container names and volumes. + +`scripts/compose-init.sh` is the compose analog of the AMI +`first-boot-secrets.sh`: it generates the shared secrets once into +`deploy/docker/.env.compose` (gitignored, mode 0600) and refuses to overwrite +(regenerating would invalidate encrypted rows / issued keys). The compose file +reads secrets only from that env file — never inline yaml. + +## Helm (production) + +`deploy/helm/nexus-gateway/` is an umbrella chart: four service Deployments + +Services, an Ingress that terminates at the console (which reverse-proxies +gateway + hub internally — one external surface), a shared-secret Secret, and a +`db-init` pre-install/pre-upgrade hook Job. `aiGateway.replicas` defaults to 2 +(the load-bearing tier); `complianceProxy.enabled=false` drops that layer +entirely. + +Backing stores are subchart dependencies (Bitnami PostgreSQL/Valkey, NATS) +enabled by default for dev/demo; production sets `*.enabled=false` and points +`external.{databaseUrl,redisAddr,natsUrl}` at managed endpoints +(`values-production.yaml`). TLS and the MITM CA are provided via existing +Secrets in production; the pre-install path generates self-signed material for +dev/demo. + +## Shared-secret contract + +Same five `[MUST MATCH]` secrets as the appliance (see `.env.example` and +`ami-appliance-architecture.md` §secrets): `INTERNAL_SERVICE_TOKEN`, +`ADMIN_KEY_HMAC_SECRET`, `CREDENTIAL_ENCRYPTION_KEY`, +`COMPLIANCE_PROXY_API_TOKEN`, `AI_GATEWAY_API_TOKEN`. In compose they live in +the generated `.env.compose`; in Helm they come from `secrets.existingSecret` +or a chart-generated Secret whose values **persist across upgrades** via +`lookup` (never rotated underneath encrypted data). Secrets are injected as env +vars only — no secret ever appears in a committed yaml. + +## Publish pipeline + +`.github/workflows/docker-publish.yml` builds on `v*` tags (or manual dispatch) +and gates publishing in three stages: + +- **Gate A** — the in-Dockerfile Vectorscan self-test + binary tag check. +- **Gate B** — `hs-selfcheck` executed inside each built runtime image, proving + the engine works in the exact shipped environment (catches `FAT_RUNTIME` + mistakes a successful build would miss). +- **Gate C** — a full-stack compose smoke (`deploy/docker/smoke-redaction.sh`): + brings the stack up, logs in as the bootstrap admin, enables the pii-detector + hook, and sends a PII-bearing request through the gateway asserting the + compliance pipeline blocks/redacts it (never echoes the raw SSN). + +Only after all three pass are the images tagged (``, ``, `latest`) +and pushed to `docker.io/alphabitcore/*`. Correctness on every PR remains the +job of `ci.yml` / `go-ci.yml`; this workflow gates *publishing*. diff --git a/docs/users/product/deployment-models.md b/docs/users/product/deployment-models.md index cd53c605..dbea1329 100644 --- a/docs/users/product/deployment-models.md +++ b/docs/users/product/deployment-models.md @@ -8,7 +8,13 @@ In the hosted model, the platform's management plane is operated for you: you do ## Self-hosted -In the self-hosted model, you run the whole stack on your own infrastructure. The backing stores — PostgreSQL, the cache, and the message queue — come up via the project's container compose definition, and the five services and the console run on top of them. A single node is enough for a trial or a small deployment, and the same components scale out for larger fleets. You own the data, the network boundary, and the upgrade cadence. The operator documentation covers the bring-up, single-node and scaled topologies, certificates, backup and recovery, and monitoring. +In the self-hosted model, you run the whole stack on your own infrastructure. There are three packagings, all running the same components: + +- **AWS Marketplace AMI / bare-metal appliance** — one image with everything baked in (services, console, Postgres, cache, message queue, nginx), managed by systemd. Best for a single-instance appliance. +- **Docker (dev / demo)** — the full stack from published `alphabitcore/nexus-*` images via `docker-compose.full.yml`, with the backing stores pulled from upstream images. `./scripts/compose-init.sh` generates the shared secrets once; `docker compose -f docker-compose.full.yml up` brings it up. Best for a quick trial or demo environment. +- **Kubernetes / Helm (production)** — the `deploy/helm/nexus-gateway` chart runs the four service workloads (console, hub, AI gateway, compliance proxy) with backing stores either in-cluster or pointed at managed Postgres / cache / message queue. The AI gateway scales independently as the load-bearing tier. Best for production clusters. + +A single node is enough for a trial or a small deployment, and the same components scale out for larger fleets. You own the data, the network boundary, and the upgrade cadence. The operator documentation covers the bring-up, single-node and scaled topologies, certificates, backup and recovery, and monitoring. ## Air-gapped @@ -24,7 +30,10 @@ The three differ only in operations and isolation, not in what the product does: ## References -- `docker-compose.yml` and `scripts/dev-start.sh` — the self-hosted bring-up of the backing stores and services +- `docker-compose.yml` and `scripts/dev-start.sh` — local-development bring-up of the backing stores (services run from source) +- `docker-compose.full.yml` and `scripts/compose-init.sh` — the full containerized stack from published images (dev / demo) +- `deploy/helm/nexus-gateway/` — the production Kubernetes chart +- `docs/developers/architecture/cross-cutting/deployment/container-deployment-architecture.md` — image, compose, and Helm design - `packages/nexus-hub/`, `packages/control-plane/`, `packages/ai-gateway/`, `packages/compliance-proxy/`, `packages/agent/` — the five services that make up the stack - `docs/operators/ops/deployment.md` and `docs/operators/ops/ec2-single-node.md` — operator deployment and single-node topology - `docs/operators/ops/runbooks/air-gapped-deployment.md` — the air-gapped procedure diff --git a/packages/ai-gateway/Dockerfile b/packages/ai-gateway/Dockerfile index fb4307e2..1474cd1a 100644 --- a/packages/ai-gateway/Dockerfile +++ b/packages/ai-gateway/Dockerfile @@ -1,18 +1,110 @@ -FROM golang:1.25-alpine AS builder -WORKDIR /app +# syntax=docker/dockerfile:1 +# Nexus AI Gateway — production container image. +# +# Three-stage cgo build linking Vectorscan (libhs) statically: +# Stage `libhs` compiles libhs from source for THIS target arch. +# Stage `builder` builds the Go binary with -tags vectorscan and hard-gates +# on the engine actually working before the image can exist. +# Final stage debian-slim runtime, nonroot. +# +# A default no-tag build silently falls back to the pure-Go RE2 matcher — +# functionally correct but far slower under load. This image must never ship +# that fallback, so the build fails loudly instead (Gate 1 + Gate 2 below). +# Build context: repository root. +# docker build -f packages/ai-gateway/Dockerfile -t alphabitcore/nexus-ai-gateway:test . + +ARG VECTORSCAN_VERSION=5.4.11 + +FROM debian:bookworm-slim AS libhs +ARG VECTORSCAN_VERSION +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git cmake make g++ ragel libboost-dev libsqlite3-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch vectorscan/${VECTORSCAN_VERSION} \ + https://github.com/VectorCamp/vectorscan.git /src/vectorscan +# FAT_RUNTIME must stay OFF: with ON the library compiles and links fine but +# hs_alloc_scratch fails inside a cgo binary and content scanning never fires +# — PII would pass through unredacted with no error surfaced anywhere. +# BUILD_AVX512 stays OFF so the binary does not SIGILL on hosts without +# avx512vbmi. +RUN cmake -S /src/vectorscan -B /build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DFAT_RUNTIME=OFF \ + -DBUILD_AVX512=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_UNIT=OFF \ + && cmake --build /build -j"$(nproc)" \ + && cmake --install /build --prefix /usr/local -COPY go.work go.work.sum ./ +FROM golang:1.25-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends g++ pkg-config \ + && rm -rf /var/lib/apt/lists/* +COPY --from=libhs /usr/local/include/hs /usr/local/include/hs +COPY --from=libhs /usr/local/lib /usr/local/lib +WORKDIR /app +# GOWORK=off: the image carries only this module + its sibling `replace` +# target (../shared), not the full go.work module set. COPY packages/shared/ packages/shared/ COPY packages/ai-gateway/ packages/ai-gateway/ - +# Static-link variant (the `vsstatic` build tag): the archive is named +# explicitly and the C++ runtime is linked AFTER it, so libhs's C++ exception +# symbols (__cxa_*) resolve deterministically regardless of dead-code +# elimination. (pkg-config ordering left -lstdc++ unresolved for the +# hs_compile/rose path.) Same model as the agent's static universal build. +ENV GOWORK=off \ + CGO_ENABLED=1 \ + CGO_CFLAGS="-I/usr/local/include -I/usr/local/include/hs" \ + CGO_LDFLAGS="/usr/local/lib/libhs.a -lstdc++ -lm" +# Gate 1: the engine must work at build time. HSSelfTest exercises +# hs_compile + hs_alloc_scratch + hs_scan — the only check that catches a +# FAT_RUNTIME / link mistake ("build succeeded" does not). +RUN mkdir -p packages/shared/policy/hooks/matcher/hsselfcheck \ + && printf '%s\n' \ + 'package main' \ + '' \ + 'import (' \ + ' "fmt"' \ + ' "os"' \ + '' \ + ' "github.com/AlphaBitCore/nexus-gateway/packages/shared/policy/hooks/matcher"' \ + ')' \ + '' \ + 'func main() {' \ + ' ver, rc, matches := matcher.HSSelfTest()' \ + ' fmt.Printf("vectorscan selftest: version=%s scanRC=%d matches=%d\n", ver, rc, matches)' \ + ' if rc != 0 || matches < 1 {' \ + ' fmt.Fprintln(os.Stderr, "FATAL: vectorscan linked but scratch/scan failed (FAT_RUNTIME=ON? bad libhs?)")' \ + ' os.Exit(1)' \ + ' }' \ + '}' \ + > packages/shared/policy/hooks/matcher/hsselfcheck/main.go \ + && cd packages/shared \ + && go build -tags "vectorscan vsstatic" -trimpath -ldflags="-s -w" -o /hs-selfcheck \ + ./policy/hooks/matcher/hsselfcheck \ + && /hs-selfcheck \ + && rm -r policy/hooks/matcher/hsselfcheck WORKDIR /app/packages/ai-gateway -RUN go build -ldflags="-s -w" -o /ai-gateway ./cmd/ai-gateway/ +RUN go build -tags "vectorscan vsstatic" -trimpath -ldflags="-s -w" -o /ai-gateway ./cmd/ai-gateway/ +# Gate 2: the shipped binary itself must carry the vectorscan tag — never +# ship the silent RE2 fallback. +RUN go version -m /ai-gateway | grep -q -- '-tags=vectorscan' \ + || { echo 'FATAL: ai-gateway built without -tags vectorscan (RE2 fallback image)'; exit 1; } -FROM alpine:3.21 -RUN apk add --no-cache ca-certificates && \ - adduser -D -u 65534 nonroot -USER nonroot +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gettext-base curl libstdc++6 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 65532 --create-home nexus \ + && mkdir -p /etc/nexus && chown nexus:nexus /etc/nexus COPY --from=builder /ai-gateway /usr/local/bin/ai-gateway - +# Shipped so CI (and operators) can verify the engine inside the exact runtime +# environment: docker run --entrypoint hs-selfcheck +COPY --from=builder /hs-selfcheck /usr/local/bin/hs-selfcheck +COPY deploy/docker/ai-gateway/config.yaml.tpl /opt/nexus/ai-gateway.config.yaml.tpl +COPY deploy/docker/ai-gateway/entrypoint.sh /usr/local/bin/nexus-entrypoint.sh +RUN chmod 0755 /usr/local/bin/nexus-entrypoint.sh +USER nexus EXPOSE 3050 -ENTRYPOINT ["ai-gateway"] +ENTRYPOINT ["nexus-entrypoint.sh"] diff --git a/packages/compliance-proxy/Dockerfile b/packages/compliance-proxy/Dockerfile index 464d3682..6215a417 100644 --- a/packages/compliance-proxy/Dockerfile +++ b/packages/compliance-proxy/Dockerfile @@ -1,18 +1,101 @@ -FROM golang:1.25-alpine AS builder +# syntax=docker/dockerfile:1 +# Nexus Compliance Proxy — production container image. +# +# Same vectorscan build discipline as the AI Gateway (see +# packages/ai-gateway/Dockerfile for the full rationale): static per-arch +# libhs with FAT_RUNTIME=OFF + BUILD_AVX512=OFF, cgo build under +# -tags vectorscan, and two hard gates so a silent RE2-fallback image can +# never ship. +# Build context: repository root (the module depends on ../shared via a +# sibling replace directive, so a package-dir context cannot build). +# docker build -f packages/compliance-proxy/Dockerfile -t alphabitcore/nexus-compliance-proxy:test . -WORKDIR /build -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o nexus-compliance-proxy ./cmd/compliance-proxy +ARG VECTORSCAN_VERSION=5.4.11 -FROM gcr.io/distroless/static-debian12:nonroot +FROM debian:bookworm-slim AS libhs +ARG VECTORSCAN_VERSION +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git cmake make g++ ragel libboost-dev libsqlite3-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch vectorscan/${VECTORSCAN_VERSION} \ + https://github.com/VectorCamp/vectorscan.git /src/vectorscan +# FAT_RUNTIME=OFF is mandatory (silent hs_alloc_scratch failure in cgo +# otherwise); BUILD_AVX512=OFF for CPU portability. +RUN cmake -S /src/vectorscan -B /build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DFAT_RUNTIME=OFF \ + -DBUILD_AVX512=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_UNIT=OFF \ + && cmake --build /build -j"$(nproc)" \ + && cmake --install /build --prefix /usr/local -COPY --from=builder /build/nexus-compliance-proxy /usr/local/bin/nexus-compliance-proxy +FROM golang:1.25-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends g++ pkg-config \ + && rm -rf /var/lib/apt/lists/* +COPY --from=libhs /usr/local/include/hs /usr/local/include/hs +COPY --from=libhs /usr/local/lib /usr/local/lib +WORKDIR /app +# GOWORK=off: the image carries only this module + its sibling `replace` +# target (../shared), not the full go.work module set. +COPY packages/shared/ packages/shared/ +COPY packages/compliance-proxy/ packages/compliance-proxy/ +# Static-link variant (`vsstatic` tag): archive named explicitly, C++ runtime +# linked AFTER it so libhs's __cxa_* exception symbols resolve deterministically +# (pkg-config ordering left them unresolved on the hs_compile/rose path). +ENV GOWORK=off \ + CGO_ENABLED=1 \ + CGO_CFLAGS="-I/usr/local/include -I/usr/local/include/hs" \ + CGO_LDFLAGS="/usr/local/lib/libhs.a -lstdc++ -lm" +# Gate 1: engine self-test at build time (catches FAT_RUNTIME/link mistakes). +RUN mkdir -p packages/shared/policy/hooks/matcher/hsselfcheck \ + && printf '%s\n' \ + 'package main' \ + '' \ + 'import (' \ + ' "fmt"' \ + ' "os"' \ + '' \ + ' "github.com/AlphaBitCore/nexus-gateway/packages/shared/policy/hooks/matcher"' \ + ')' \ + '' \ + 'func main() {' \ + ' ver, rc, matches := matcher.HSSelfTest()' \ + ' fmt.Printf("vectorscan selftest: version=%s scanRC=%d matches=%d\n", ver, rc, matches)' \ + ' if rc != 0 || matches < 1 {' \ + ' fmt.Fprintln(os.Stderr, "FATAL: vectorscan linked but scratch/scan failed (FAT_RUNTIME=ON? bad libhs?)")' \ + ' os.Exit(1)' \ + ' }' \ + '}' \ + > packages/shared/policy/hooks/matcher/hsselfcheck/main.go \ + && cd packages/shared \ + && go build -tags "vectorscan vsstatic" -trimpath -ldflags="-s -w" -o /hs-selfcheck \ + ./policy/hooks/matcher/hsselfcheck \ + && /hs-selfcheck \ + && rm -r policy/hooks/matcher/hsselfcheck +WORKDIR /app/packages/compliance-proxy +RUN go build -tags "vectorscan vsstatic" -trimpath -ldflags="-s -w" -o /nexus-compliance-proxy ./cmd/compliance-proxy +# Gate 2: the shipped binary must carry the vectorscan tag. +RUN go version -m /nexus-compliance-proxy | grep -q -- '-tags=vectorscan' \ + || { echo 'FATAL: compliance-proxy built without -tags vectorscan (RE2 fallback image)'; exit 1; } -EXPOSE 3128 9090 - -USER nonroot:nonroot - -ENTRYPOINT ["/usr/local/bin/nexus-compliance-proxy"] -CMD ["-config", "/etc/compliance-proxy/compliance-proxy.config.yaml"] +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gettext-base curl libstdc++6 openssl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 65532 --create-home nexus \ + && mkdir -p /etc/nexus /etc/compliance-proxy /var/lib/nexus/alerting /var/lib/nexus/audit-spool \ + && chown -R nexus:nexus /etc/nexus /etc/compliance-proxy /var/lib/nexus +COPY --from=builder /nexus-compliance-proxy /usr/local/bin/nexus-compliance-proxy +# Shipped so CI (and operators) can verify the engine inside the exact runtime +# environment: docker run --entrypoint hs-selfcheck +COPY --from=builder /hs-selfcheck /usr/local/bin/hs-selfcheck +COPY deploy/docker/compliance-proxy/config.yaml.tpl /opt/nexus/compliance-proxy.config.yaml.tpl +COPY deploy/docker/compliance-proxy/entrypoint.sh /usr/local/bin/nexus-entrypoint.sh +RUN chmod 0755 /usr/local/bin/nexus-entrypoint.sh +USER nexus +# 3128 CONNECT proxy · 3040 runtime API (console control-plane calls it) · 9090 metrics +EXPOSE 3128 3040 9090 +ENTRYPOINT ["nexus-entrypoint.sh"] diff --git a/packages/nexus-hub/Dockerfile b/packages/nexus-hub/Dockerfile index 3ec46062..81e3effe 100644 --- a/packages/nexus-hub/Dockerfile +++ b/packages/nexus-hub/Dockerfile @@ -1,20 +1,31 @@ +# syntax=docker/dockerfile:1 +# Nexus Hub — production container image. Pure Go (no cgo, no vectorscan). +# Build context: repository root. +# docker build -f packages/nexus-hub/Dockerfile -t alphabitcore/nexus-hub:test . + FROM golang:1.25-alpine AS builder WORKDIR /app -COPY go.work go.work.sum ./ +# GOWORK=off: the image carries only this module + its sibling `replace` +# target (../shared), not the full go.work module set. COPY packages/shared/ packages/shared/ COPY packages/nexus-hub/ packages/nexus-hub/ WORKDIR /app/packages/nexus-hub ARG BUILD_VERSION=dev -RUN go build -ldflags="-s -w -X main.buildVersion=${BUILD_VERSION}" \ +RUN GOWORK=off CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.buildVersion=${BUILD_VERSION}" \ -o /nexus-hub ./cmd/nexus-hub/ FROM alpine:3.21 -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates gettext curl && \ + adduser -D -u 65532 nexus && \ + mkdir -p /etc/nexus /var/lib/nexus/agentca && \ + chown -R nexus:nexus /etc/nexus /var/lib/nexus COPY --from=builder /nexus-hub /usr/local/bin/nexus-hub -COPY packages/nexus-hub/nexus-hub.config.yaml /etc/nexus-hub/config.yaml - +COPY deploy/docker/hub/config.yaml.tpl /opt/nexus/nexus-hub.config.yaml.tpl +COPY deploy/docker/hub/entrypoint.sh /usr/local/bin/nexus-entrypoint.sh +RUN chmod 0755 /usr/local/bin/nexus-entrypoint.sh +USER nexus EXPOSE 3060 -ENTRYPOINT ["nexus-hub"] -CMD ["--config", "/etc/nexus-hub/config.yaml"] +ENTRYPOINT ["nexus-entrypoint.sh"] diff --git a/scripts/compose-init.sh b/scripts/compose-init.sh new file mode 100755 index 00000000..460b59a7 --- /dev/null +++ b/scripts/compose-init.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# compose-init.sh — one-time secret generation for docker-compose.full.yml. +# Container analog of nexus-ami/scripts/first-boot-secrets.sh: generates the +# shared secrets every service must agree on ([MUST MATCH] in .env.example) +# and writes them to deploy/docker/.env.compose (gitignored, mode 0600). +# +# Idempotent: refuses to overwrite an existing env file — regenerating would +# invalidate encrypted DB rows (CREDENTIAL_ENCRYPTION_KEY), already-issued +# admin keys (ADMIN_KEY_HMAC_SECRET), and inter-service auth +# (INTERNAL_SERVICE_TOKEN). Delete the file AND the volumes to start over: +# docker compose -f docker-compose.full.yml down -v +# rm deploy/docker/.env.compose + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$REPO_ROOT/deploy/docker/.env.compose" + +if [ -f "$ENV_FILE" ]; then + echo "compose-init: $ENV_FILE already exists — keeping it (see header for why)." + exit 0 +fi + +mkdir -p "$(dirname "$ENV_FILE")" +umask 177 + +cat > "$ENV_FILE" <