Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ jobs:
env:
GW_TEST_PG_URL: postgres://postgres:gwtest@127.0.0.1:5432/gw
GW_TEST_REDIS_URL: redis://127.0.0.1:6379
CP_TEST_PG_URL: postgres://postgres:gwtest@127.0.0.1:5432/gw?sslmode=disable
CP_TEST_REDIS_URL: redis://127.0.0.1:6379/0
# built by the cargo test step; drives the tenant-token full-chain E2E
CP_TEST_GW_BIN: ${{ github.workspace }}/target/debug/gw
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -45,6 +49,35 @@ jobs:
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
run: cargo test --workspace
- uses: actions/setup-go@v6
with:
go-version-file: control-plane/go.mod
cache-dependency-path: control-plane/go.sum
- name: Control-plane Go gates
working-directory: control-plane
run: |
GOWORK=off go vet ./...
GOWORK=off go test ./...
GOWORK=off go test -tags=integration -count=1 ./internal/integration
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: control-plane/web/package-lock.json
- name: Control-plane web gates
working-directory: control-plane/web
run: |
npm ci
npm test
npm run build
- name: Install browser
working-directory: control-plane/web
run: npx playwright install --with-deps chromium
- name: Control-plane browser E2E
working-directory: control-plane/web
run: npm run e2e
- name: Control-plane image builds
run: docker build control-plane/

deny:
runs-on: ubuntu-latest
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: docker/metadata-action@v5
id: meta-cp
with:
images: ghcr.io/${{ github.repository }}-control-plane
- uses: docker/build-push-action@v6
with:
context: control-plane
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-cp.outputs.tags }}
labels: ${{ steps.meta-cp.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
47 changes: 47 additions & 0 deletions .github/workflows/release-control-plane.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Release control plane

on:
push:
tags: ["v*"]

permissions:
contents: write

jobs:
binaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
with:
go-version-file: control-plane/go.mod
cache-dependency-path: control-plane/go.sum
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: control-plane/web/package-lock.json
- name: Build web assets
working-directory: control-plane/web
run: |
npm ci
npm run build
- name: Build and package binaries
working-directory: control-plane
run: |
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="${target%/*}" arch="${target#*/}"
out="dist/control-plane_${GITHUB_REF_NAME}_${os}_${arch}"
mkdir -p "$out"
GOWORK=off CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w" -o "$out/control-plane" ./cmd/control-plane
cp -r web/dist "$out/web-dist"
tar -czf "$out.tar.gz" -C dist "$(basename "$out")"
done
- name: Publish release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \
|| gh release create "$GITHUB_REF_NAME" --generate-notes
gh release upload "$GITHUB_REF_NAME" control-plane/dist/*.tar.gz --clobber
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/target
/control-plane/web/node_modules
/control-plane/web/dist
/control-plane/web/playwright-report
/control-plane/web/test-results
*.tsbuildinfo
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
BIN := gw

.PHONY: all build release test lint fmt fmt-check deny dist dist-plan docker run clean
.PHONY: all build release test lint fmt fmt-check deny dist dist-plan docker run clean control-plane control-plane-test control-plane-integration

all: fmt lint test build

Expand Down Expand Up @@ -38,5 +38,14 @@ docker:
run:
cargo run -p gw-server

control-plane:
$(MAKE) -C control-plane build

control-plane-test:
$(MAKE) -C control-plane test web-test

control-plane-integration:
$(MAKE) -C control-plane test-integration

clean:
cargo clean
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ key-based auth, quotas, rate limits, failover, and a billing ledger.
- **Providers behind traits** — engines talk to upstreams through a `Transport` seam; accounts with a real endpoint go over HTTP (reqwest + rustls), accounts without one are served by a deterministic in-process mock; AWS SigV4 signing included
- **Observability built in** — Prometheus `/metrics` (per-route request/status counters, per-pipeline-stage latency, token counters), structured access logs
- **One binary, one YAML** — no external services required to start; in-process state by default, SQLite for one-node durability, Postgres + Redis for a shared fleet; graceful shutdown
- **Web control plane** — a role-aware browser console ([`control-plane/`](control-plane/)): members see their own usage and charges, tenant admins manage keys and security events under gateway-enforced tenant scope, system admins get fleet economics, instance health, config publish/rollback and audit. Go BFF + React UI, its own identity store, everything proxied through the gateway admin API

## Quick Start

Expand Down Expand Up @@ -54,7 +55,9 @@ docker run -p 8080:8080 -v $PWD/conf/gateway.yaml:/etc/gateway.yaml \
```

The image binds `0.0.0.0` (`GW_HOST`) and ships a `/health` HEALTHCHECK.
Published multi-arch to `ghcr.io/cocoonstack/gateway` on `v*` tags.
Published multi-arch (amd64 + arm64) to `ghcr.io/cocoonstack/gateway` on `v*`
tags, alongside `ghcr.io/cocoonstack/gateway-control-plane` and control-plane
binary tarballs (linux/darwin × amd64/arm64) on the GitHub Release.

## Development

Expand Down
4 changes: 4 additions & 0 deletions control-plane/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
web/node_modules
web/dist
web/test-results
web/playwright-report
24 changes: 24 additions & 0 deletions control-plane/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM node:24-alpine AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build

FROM golang:1.25.6-alpine AS backend
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /control-plane ./cmd/control-plane

FROM alpine:3.23
RUN apk add --no-cache ca-certificates \
&& adduser -D -H -u 10001 controlplane
COPY --from=backend /control-plane /usr/local/bin/control-plane
COPY --from=web /src/web/dist /srv/control-plane/web
ENV CP_LISTEN=0.0.0.0:8090 CP_WEB_DIR=/srv/control-plane/web CP_COOKIE_SECURE=true
USER controlplane
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q -O /dev/null http://127.0.0.1:8090/api/v1/health || exit 1
ENTRYPOINT ["/usr/local/bin/control-plane"]
30 changes: 30 additions & 0 deletions control-plane/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.PHONY: test test-integration web web-test build

RUST_CARGO ?= cargo

test:
GOWORK=off go test ./...

test-integration:
@set -e; trap 'docker compose -f compose.test.yaml down -v' EXIT; \
docker compose -f compose.test.yaml up -d --wait; \
$(RUST_CARGO) build --manifest-path ../Cargo.toml -p gw-server; \
CP_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane?sslmode=disable' \
CP_TEST_REDIS_URL='redis://127.0.0.1:56379/0' \
CP_TEST_GW_BIN="$$(cd .. && pwd)/target/debug/gw" \
GOWORK=off go test -tags=integration -count=1 -v ./internal/integration; \
GW_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane' \
GW_TEST_REDIS_URL='redis://127.0.0.1:56379' \
$(RUST_CARGO) test --manifest-path ../Cargo.toml -p gw-state; \
GW_TEST_PG_URL='postgres://postgres:controlplane@127.0.0.1:55432/controlplane' \
$(RUST_CARGO) test --manifest-path ../Cargo.toml -p gw-server --test e2e admin_config_publish_reloads_from_store

web-test:
npm --prefix web test
npm --prefix web run build

web:
npm --prefix web run build

build: web
GOWORK=off go build ./cmd/control-plane
126 changes: 126 additions & 0 deletions control-plane/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Gateway control plane

The control plane is a small Go BFF with a React/TypeScript browser UI. It owns
human identities and browser sessions; it does **not** read or mutate the Rust
gateway's Postgres tables or Redis keys. Usage, billing, model status, access
keys, audit data and configuration all cross the Rust gateway admin HTTP API.

## Roles

| Role | Scope | UI and API access |
| --- | --- | --- |
| `member` | One gateway tenant and user ID | Own usage, charges and model availability |
| `tenant_admin` | One gateway tenant | Tenant usage, key lifecycle and security events |
| `system_admin` | Global | Fleet economics, instances/accounts, users, keys, audit and configuration |

The Go service derives tenant/user filters from the authenticated session. It
never trusts browser-supplied scope for a member or tenant administrator, and it
removes vendor cost from non-system responses.

Tenant boundaries on key mutations are enforced by the **gateway**, not this
process: when a tenant administrator acts, the control plane authenticates with
that tenant's scoped gateway admin token (`CP_GATEWAY_TENANT_TOKENS`, matching
the gateway's per-tenant `admin_token_env`), so the gateway's own
`AdminScope` checks draw the line atomically. Mutations fail closed — a tenant
admin without a configured tenant token cannot mutate keys at all. The gateway
additionally redacts vendor cost for tenant-scoped tokens server-side.

Every request carries an `X-Request-ID` (caller-supplied or generated): it is
echoed in the response, stamped on access and audit log lines as `rid=`, and
forwarded on every proxied gateway call.

## Gateway instances

`CP_GATEWAY_TARGETS` is a deliberately simple ordered list such as:

```text
gw-a=http://gateway-a:8080,gw-b=http://gateway-b:8080
```

The first target handles shared admin reads and mutations. Gateway configuration
and keys already converge through the gateway's own Postgres/Redis mechanisms.
The control plane polls every configured target in parallel for `/health` and
`/internal/accounts`, so an operator can distinguish instance reachability from
account-pool health and client-visible model availability. There is no second
service-discovery or leader-election system in this project.

## Local development

The default configuration uses an in-memory identity store, in-memory session
KV and a deterministic gateway mock:

```sh
cd control-plane
CP_DEV_SEED=true go run ./cmd/control-plane
```

In another terminal:

```sh
cd control-plane/web
npm install
npm run dev
```

Open <http://127.0.0.1:5173>. Development accounts are:

| Role | Email | Password |
| --- | --- | --- |
| System admin | `admin@example.com` | `admin12345!` |
| Tenant admin | `manager@example.com` | `manager123!` |
| Member | `user@example.com` | `user12345!` |

These accounts exist only when `CP_DEV_SEED=true` is set explicitly — the
default is always false, for every store backend.

## Configuration

| Variable | Default | Purpose |
| --- | --- | --- |
| `CP_LISTEN` | `127.0.0.1:8090` | HTTP listen address |
| `CP_STORE` | `memory` | `memory` or `postgres` for control-plane users |
| `CP_DATABASE_URL` | — | Control-plane Postgres URL |
| `CP_KV` | `memory` | `memory` or `redis` for browser sessions |
| `CP_REDIS_URL` | — | Control-plane Redis URL |
| `CP_DEV_SEED` | `false` | Seed fixed demo accounts (never enable in production) |
| `CP_GATEWAY_MODE` | `mock` | `mock` or `http` |
| `CP_GATEWAY_TARGETS` | `local=http://127.0.0.1:8080` | Comma-separated `id=url` targets |
| `CP_GATEWAY_ADMIN_TOKEN` | — | Global Rust gateway admin bearer token |
| `CP_GATEWAY_TENANT_TOKENS` | — | Comma-separated `tenant=token` scoped gateway admin tokens |
| `CP_WEB_DIR` | `web/dist` | Built browser assets |
| `CP_SESSION_TTL` | `12h` | Browser session lifetime |
| `CP_COOKIE_SECURE` | `false` | Secure cookie flag; enable behind HTTPS |
| `CP_BOOTSTRAP_ADMIN_EMAIL` | — | Optional first Postgres system admin |
| `CP_BOOTSTRAP_ADMIN_PASSWORD` | — | Password paired with bootstrap email |

The Postgres schema contains only `users`. Redis uses only
`gateway:control-plane:session:*`. Neither connection points at gateway-owned
state.

## Tests and builds

```sh
make test # Go unit tests
make web-test # Vitest plus production UI build
make test-integration # Local Postgres 16 + Redis 7 containers
make build # Browser assets plus Go binary
```

The browser E2E suite runs the in-memory/mock stack:

```sh
cd web
npx playwright install chromium
npm run e2e
```

The BFF contract is in [`api/openapi.yaml`](api/openapi.yaml); a Go test
(`internal/httpapi/openapi_test.go`) fails CI when routes and spec drift. Rust
admin API details remain in [`../docs/api.md`](../docs/api.md).

## Releases

Version tags publish multi-arch (`linux/amd64` + `linux/arm64`) Docker images
for the gateway and the control plane (`.github/workflows/docker.yml`), plus
control-plane binary tarballs for linux/darwin × amd64/arm64 with the built
web assets bundled (`.github/workflows/release.yml`).
Loading
Loading