diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..08e81cad6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,325 @@ +# Contributing to Ctrlplane + +Thanks for your interest in contributing! This guide covers everything you need to get a local dev environment running, find your way around the repo, and open a pull request we can review quickly. + +If you're planning a larger change, please **open an issue or GitHub Discussion first** so we can align on the approach before you invest time in it. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Help](#getting-help) +- [Reporting Bugs and Requesting Features](#reporting-bugs-and-requesting-features) +- [Development Setup](#development-setup) +- [Repository Structure](#repository-structure) +- [Architecture at a Glance](#architecture-at-a-glance) +- [Service-Specific Guides](#service-specific-guides) +- [Code Style and Conventions](#code-style-and-conventions) +- [Testing](#testing) +- [Commit Messages](#commit-messages) +- [Opening a Pull Request](#opening-a-pull-request) + +## Code of Conduct + +This project follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating, you agree to uphold it. Report unacceptable behavior to `conduct@ctrlplane.dev`. + +## Getting Help + +- **Questions about using Ctrlplane** → [GitHub Discussions](https://github.com/ctrlplanedev/ctrlplane/discussions) or [Discord](https://ctrlplane.dev/discord) +- **Contributor questions** → `#contributors` on Discord, or comment on an issue +- **Security vulnerabilities** → email `security@ctrlplane.dev`; please do not open a public issue. See [SECURITY.md](SECURITY.md) for details. + +## Reporting Bugs and Requesting Features + +- **Bugs**: open a [bug report](https://github.com/ctrlplanedev/ctrlplane/issues/new?template=bug_report.yml) with reproduction steps, expected vs. actual behavior, and your environment (OS, Node version, Go version if relevant). +- **Features / enhancements**: open a [feature request](https://github.com/ctrlplanedev/ctrlplane/issues/new?template=feature_request.yml) describing the problem you're solving, not just the solution you have in mind. +- Browse [`good first issue`](https://github.com/ctrlplanedev/ctrlplane/labels/good%20first%20issue) for beginner-friendly work and [`help wanted`](https://github.com/ctrlplanedev/ctrlplane/labels/help%20wanted) for where we'd appreciate outside help. + +## Development Setup + +### Prerequisites + +| Tool | Version | Notes | +| ------- | ------------ | -------------------------------------------------- | +| Node.js | `>= 22.10.0` | Managed by Flox, or install directly | +| pnpm | `^10.2.0` | Managed by Flox, or install directly | +| Go | `1.26+` | Only needed for `workspace-engine` | +| Docker | latest | For Postgres, Kafka, and local observability stack | + +**Recommended path**: install [Flox](https://flox.dev/docs/install-flox/install/) and run `flox activate` in the repo — it provisions Node, pnpm, Go, and the rest of the toolchain at the versions this repo expects. + +**Manual path**: install Node, pnpm, Go, and Docker yourself. Any compatible versions work. + +### First-Time Setup + +```bash +git clone https://github.com/ctrlplanedev/ctrlplane.git +cd ctrlplane + +# (Recommended) activate the tooling environment +flox activate + +# Create your local env file from the example +cp .env.example .env + +# Start local services (Postgres, Kafka, Jaeger, Prometheus, OTel collector) +docker compose -f docker-compose.dev.yaml up -d + +# Install dependencies and build all packages +pnpm i && pnpm build + +# Apply database migrations +pnpm -F @ctrlplane/db migrate + +# Start all dev servers +pnpm dev +``` + +Once everything is running: + +- **Web app**: http://localhost:5173 +- **API**: http://localhost:3000 (tRPC + REST) +- **workspace-engine**: http://localhost:8081 +- **Jaeger (traces)**: http://localhost:16686 +- **Prometheus (metrics)**: http://localhost:9999 +- **Drizzle Studio (DB UI)**: `pnpm -F @ctrlplane/db studio` + +**Logging in locally**: with no OAuth providers configured, the app falls back to email + password auth. Visit the web app, sign up with any email/password, and you're in. To test OAuth flows, set `AUTH_GOOGLE_CLIENT_ID` / `AUTH_OKTA_*` / `AUTH_OIDC_*` in `.env`. + +### Resetting Your Environment + +When you need a clean slate — corrupted DB, schema conflicts, stale Kafka state: + +```bash +docker compose -f docker-compose.dev.yaml down -v # wipes all volumes +docker compose -f docker-compose.dev.yaml up -d +pnpm -F @ctrlplane/db migrate +pnpm dev +``` + +### Day-to-Day Commands + +| Command | Description | +| --------------------------------- | ----------------------------------------- | +| `pnpm dev` | Start all dev servers (hot reload) | +| `pnpm build` | Build all packages | +| `pnpm test` | Run all TypeScript tests | +| `pnpm lint` | Lint all TypeScript code | +| `pnpm lint:fix` | Auto-fix lint errors | +| `pnpm format:fix` | Auto-format all TypeScript code | +| `pnpm typecheck` | TypeScript type check across all packages | +| `pnpm -F test` | Run tests for a specific package | +| `pnpm -F test -- -t "name"` | Run a specific test by name | + +**Database:** + +```bash +pnpm -F @ctrlplane/db migrate # Apply pending migrations +pnpm -F @ctrlplane/db push # Push schema changes without a migration file (dev only) +pnpm -F @ctrlplane/db studio # Open Drizzle Studio UI +``` + +**workspace-engine (Go):** + +```bash +cd apps/workspace-engine +go run . # Run the service binary (without building) +go test ./... # Run tests +golangci-lint run # Lint +go fmt ./... # Format +``` + +By default `workspace-engine` runs all controllers. To run a subset (useful when debugging one), set `SERVICES` in `.env`: + +```bash +SERVICES=deployment-plan,policy-eval +``` + +## Repository Structure + +```text +apps/ + api/ # Node/Express REST + tRPC API — core business logic + web/ # React 19 + React Router frontend + workspace-engine/ # Go reconciliation engine (controllers) +packages/ + db/ # Drizzle ORM schema + migrations (PostgreSQL) + trpc/ # tRPC server setup + auth/ # better-auth integration + workspace-engine-sdk/ # Published TypeScript SDK for external integrations +integrations/ # External service adapters (GitHub, ArgoCD, Terraform Cloud, …) +e2e/ # Playwright end-to-end tests (API + UI) +tooling/ # Shared ESLint, Prettier, TypeScript configs +``` + +**Build system**: [Turborepo](https://turbo.build/) + [pnpm workspaces](https://pnpm.io/workspaces). Internal packages use the `@ctrlplane/` scope. + +## Architecture at a Glance + +```text + ┌─────────────────┐ + Your CI (e.g. GHA) ──► │ │ + │ │ ┌──────────────┐ + Webhooks ────────► │ apps/api │ ◄─tRPC──┤ apps/web │ + (GitHub, Argo, TFC) │ │ └──────────────┘ + │ │ + └────────┬────────┘ + │ enqueue work + ▼ + ┌─────────────────┐ + │ PostgreSQL │ + │ reconcile_work │ + └────────┬────────┘ + │ lease + ▼ + ┌─────────────────┐ + │ workspace-engine│ ──► Job Agents (GHA, Argo, K8s, TFC) + │ controllers │ + └─────────────────┘ +``` + +### How a release flows through the system + +1. **CI registers a version** via the API (`POST /v1/versions`). +2. **`deploymentplan` controller** computes which resources match the deployment's selector — producing release targets (deployment × environment × resource). +3. **`desiredrelease` controller** picks the target version per release target. +4. **`policyeval` controller** evaluates gates: approvals, environment ordering, deploy windows, gradual rollout. +5. **`jobdispatch` controller** routes jobs to the correct job agent (ArgoCD, GitHub Actions, K8s Jobs, Terraform Cloud, custom). +6. **`jobverificationmetric` controller** polls metrics (Datadog, Prometheus, HTTP) — if verification passes, promote; if it fails, rollback. + +### Work queue + +All reconciliation happens through a PostgreSQL-backed work queue (`reconcile_work_scope` table). Controllers lease work, process it, and can return `RequeueAfter` to schedule retries. The engine is horizontally scalable — set `SERVICES` to activate specific controllers per instance. + +### Policy engine + +Policies are declarative CEL-based rules evaluated against release targets. Rule types include `policyRuleAnyApproval`, `policyRuleEnvironmentProgression`, `policyRuleDeploymentWindow`, `policyRuleGradualRollout`, `policyRuleVerification`, `policyRuleRetry`, `policyRuleRollback`. All rule types must pass (AND); within a type, any matching rule is sufficient (OR). + +## Service-Specific Guides + +Each service has its own contributing guide with architecture depth, common recipes ("how to add an X"), and testing patterns. Start with the root setup above, then dive into the service you're working on: + +- [apps/api/CONTRIBUTING.md](apps/api/CONTRIBUTING.md) — Adding API endpoints, tRPC routers, webhooks +- [apps/web/CONTRIBUTING.md](apps/web/CONTRIBUTING.md) — React Router routes, components, tRPC hooks +- [apps/workspace-engine/CONTRIBUTING.md](apps/workspace-engine/CONTRIBUTING.md) — Adding controllers, work queue scopes, reconciliation patterns +- [packages/db/README.md](packages/db/README.md) — Schema changes, migrations, query patterns + +> These guides are a work in progress — if a section you need doesn't exist, open an issue and we'll prioritize it. + +## Code Style and Conventions + +### TypeScript + +- Explicit types on public APIs; prefer `interface` over `type` for object shapes +- `import type { … }` for type-only imports +- Named imports grouped by source: stdlib → external → internal (`@ctrlplane/*`) +- `async/await` over raw `.then()` chains +- Early returns over nested `if/else` +- Extract helpers instead of deeply nested logic +- Formatting via `@ctrlplane/prettier-config` — run `pnpm format:fix` + +### React + +- Functional components only, typed as `const Foo: React.FC = () => { … }` +- Co-locate components with their routes when feasible +- Prefer composition over prop drilling + +### Go (workspace-engine, relay) + +- Run `go fmt ./...` and `golangci-lint run` before committing +- Comments explain **why**, not **what** — skip comments that restate the code +- Table-driven tests for condition/rule logic +- Exported functions and types get doc comments + +### General + +- Don't add features, refactors, or abstractions beyond what the task requires +- Don't add error handling, fallbacks, or validation for cases that can't happen — trust internal contracts, only validate at boundaries +- Don't leave commented-out code, `TODO` comments without an issue link, or "removed X" notes + +## Testing + +Required for new code unless the change is purely docs/config. + +| Kind | Framework | Location | When to use | +| ----------------- | ------------------------------------- | -------------------------- | ------------------------------------------------------------------------ | +| E2E / Integration | [Playwright](https://playwright.dev/) | `e2e/tests/**/*.spec.ts` | API endpoints, webhooks, full-stack flows — the default for TS changes | +| Go unit tests | stdlib `testing` | `*_test.go` next to source | All `workspace-engine` logic | + +Most TypeScript changes are covered by **Playwright e2e tests** rather than per-package unit tests. Tests live in `e2e/tests/` and use YAML fixture files (`*.spec.yaml` alongside `*.spec.ts`) to declare test entities. Use `importEntitiesFromYaml` to load them and `cleanupImportedEntities` to tear them down. Pass `addRandomPrefix: true` when parallel runs might conflict. + +```bash +cd e2e +pnpm exec playwright test # Run everything +pnpm exec playwright test tests/api/resources.spec.ts # Run one file +pnpm test:api # API-only suite +pnpm test:debug # Debug mode +``` + +Before opening a PR: + +```bash +pnpm test # Runs unit tests where they exist (mainly Go) +pnpm lint +pnpm typecheck +``` + +## Commit Messages + +We use [Conventional Commits](https://www.conventionalcommits.org/). Format: + +``` +(): + + + + +``` + +**Common types**: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `ci`, `build`. + +**Examples:** + +``` +feat(api): add bulk version registration endpoint +fix(workspace-engine): prevent duplicate leases under concurrent polls +refactor(web): extract deployment selector into its own component +chore: bump better-auth to 1.4.6 +``` + +Keep the summary line under ~70 characters. If the change is non-obvious, explain the motivation in the body — the diff shows _what_, the message should cover _why_. + +## Opening a Pull Request + +1. **Fork** the repo and create a branch from `main` (e.g. `feat/bulk-versions`, `fix/lease-race`). +2. **Make your changes**, including tests. +3. **Run the checks**: `pnpm test && pnpm lint && pnpm typecheck`. +4. **Push** and open a PR against `main`. +5. **Fill out the PR template**: what changed, why, how you tested, and any follow-ups. +6. **Link the issue** it closes (`Closes #123`) if applicable. +7. **Keep the PR focused** — one logical change per PR. Split large work into a sequence of small PRs rather than one mega-PR. + +### What reviewers look for + +- Tests that meaningfully cover the change +- No unrelated drive-by edits +- Clear commit history (squash fixups locally before pushing, or let us squash-merge) +- Docs and types updated alongside code changes +- No new lint/typecheck warnings + +### After you open the PR + +- CI runs lint, typecheck, tests, and e2e. Make sure it's green. +- A maintainer will review within a few business days. Ping on Discord if it's been longer. +- Respond to review feedback by pushing new commits to the same branch — don't force-push until the review is settled. +- Once approved, a maintainer will merge. Squash-merge is the default. + +### By contributing + +You confirm that: + +- You have the right to submit the code under this repository's [LICENSE](LICENSE). +- Your contribution will be licensed under the same terms. + +--- + +Thanks again for contributing — we appreciate every issue, PR, and discussion. 🎉 diff --git a/README.md b/README.md index 71ae53723..e1d764f87 100644 --- a/README.md +++ b/README.md @@ -84,118 +84,21 @@ See our [installation guide](https://docs.ctrlplane.dev/installation) to get sta ## 🛠️ Contributing -We welcome contributions! This section covers everything you need to get the project running locally and start contributing. +We welcome contributions! See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide — covering local setup, architecture, code style, testing, commit conventions, and the PR process. -### Prerequisites - -- [Docker](https://docs.docker.com/get-docker/) engine installed and running -- [Flox](https://flox.dev/docs/install-flox/install/) installed (manages Node.js, pnpm, Go, and other tooling) -- [pnpm](https://pnpm.io/installation) (if not using Flox) -- [Go 65+](https://go.dev/dl/) (only needed if working on `workspace-engine` or `relay`) - -### First-Time Setup +Quick start for the impatient: ```bash git clone https://github.com/ctrlplanedev/ctrlplane.git cd ctrlplane - -# Activate the Flox environment (installs all required tooling) -flox activate - -# Start local services (PostgreSQL, etc.) +cp .env.example .env docker compose -f docker-compose.dev.yaml up -d - -# Install dependencies and build all packages pnpm i && pnpm build - -# Apply database migrations pnpm -F @ctrlplane/db migrate - -# Start all dev servers pnpm dev ``` -> **Reset everything** (wipe volumes and start fresh): -> -> ```bash -> docker compose -f docker-compose.dev.yaml down -v -> docker compose -f docker-compose.dev.yaml up -d -> pnpm -F @ctrlplane/db migrate -> pnpm dev -> ``` - -### Day-to-Day Development - -| Command | Description | -| ------------------------------------------ | ----------------------------------------- | -| `pnpm dev` | Start all dev servers | -| `pnpm build` | Build all packages | -| `pnpm test` | Run all TypeScript tests | -| `pnpm lint` | Lint all TypeScript code | -| `pnpm format:fix` | Auto-format all TypeScript code | -| `pnpm typecheck` | TypeScript type check across all packages | -| `pnpm -F test` | Run tests for a specific package | -| `pnpm -F test -- -t "test name"` | Run a specific test by name | - -### Database - -```bash -pnpm -F @ctrlplane/db migrate # Run migrations -pnpm -F @ctrlplane/db push # Apply schema changes without a migration file (dev only) -pnpm -F @ctrlplane/db studio # Open Drizzle Studio UI -``` - -### E2E Tests (Playwright) - -```bash -cd e2e -pnpm exec playwright test # Run all e2e tests -pnpm exec playwright test tests/api/resources.spec.ts # Run a specific file -pnpm test:api # Run all API tests -pnpm test:debug # Run in debug mode -``` - -### workspace-engine (Go) - -```bash -cd apps/workspace-engine -go run ./... # Run without building -go build -o ./bin/workspace-engine . # Build binary -go test ./... # Run tests -golangci-lint run # Lint -go fmt ./... # Format -``` - -### Monorepo Structure - -```text -apps/ - api/ # Node.js/Express REST API — core business logic - web/ # React 19 + React Router frontend - workspace-engine/ # Go reconciliation engine - relay/ # Go WebSocket relay for agent communication -packages/ - db/ # Drizzle ORM schema + migrations (PostgreSQL) - trpc/ # tRPC server setup - auth/ # Authentication integration -integrations/ # External service adapters -e2e/ # Playwright end-to-end tests -``` - -### Code Style - -- **TypeScript**: explicit types, `interface` for public APIs, `async/await`, named imports -- **React**: functional components only, typed as `const Foo: React.FC = () => {}` -- **Tests**: vitest with typed fixtures -- **Go**: follow `apps/workspace-engine/CLAUDE.md` guidelines -- Run `pnpm lint` and `pnpm format:fix` before submitting a PR - -### Opening a Pull Request - -1. Fork the repo and create a branch from `main` -2. Make your changes, add tests where applicable -3. Run `pnpm test`, `pnpm lint`, and `pnpm typecheck` to verify everything passes -4. Open a PR against `main` with a clear description of what changed and why +Then open http://localhost:5173. ## :heart: Community diff --git a/apps/api/CONTRIBUTING.md b/apps/api/CONTRIBUTING.md new file mode 100644 index 000000000..78ed9ec37 --- /dev/null +++ b/apps/api/CONTRIBUTING.md @@ -0,0 +1,355 @@ +# Contributing to `apps/api` + +This is the service guide for `apps/api` — the Node/Express service that handles REST, tRPC, webhooks, and auth. It assumes you've completed the root [CONTRIBUTING.md](../../CONTRIBUTING.md) setup and can run `pnpm dev`. + +## Table of Contents + +- [What This Service Does](#what-this-service-does) +- [Architecture](#architecture) +- [Directory Layout](#directory-layout) +- [Recipes](#recipes) + - [Add a REST endpoint](#add-a-rest-endpoint) + - [Add a webhook handler](#add-a-webhook-handler) + - [Require authentication and check access](#require-authentication-and-check-access) + - [Enqueue reconciliation work](#enqueue-reconciliation-work) + - [Return a typed error](#return-a-typed-error) +- [Testing](#testing) +- [Common Pitfalls](#common-pitfalls) + +## What This Service Does + +`apps/api` is the entry point for everything that talks to Ctrlplane from the outside: + +- **REST API** (`/api/v1/*`) — OpenAPI-specified, used by the CLI, Terraform provider, and external integrations +- **tRPC** (`/api/trpc/*`) — type-safe RPC used by the web app +- **Webhooks** (`/api/github`, `/api/tfe`, `/api/argo`) — inbound events from external systems +- **Auth** (`/api/auth/*`) — better-auth sign-in, sessions, OAuth callbacks + +It owns the synchronous request/response surface. Long-running work (computing release targets, evaluating policies, dispatching jobs) is not done here — the API validates the request, writes the change to Postgres, and enqueues work for `workspace-engine` to pick up. + +## Architecture + +```text +┌──────────────────────────────────────────────────────────────┐ +│ apps/api (Express) │ +│ │ +│ cors / helmet / body parsers / cookies / logger │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ OpenAPI validator │ validates /api/v1 │ +│ │ (skips auth/trpc/ │ requests against │ +│ │ webhooks/healthz) │ openapi.json │ +│ └─────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────┼───────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ /api/auth/* /api/v1/* (REST) /api/trpc/* │ +│ better-auth requireAuth + routers tRPC middleware │ +│ │ +│ /api/github/* /api/tfe/* /api/argo/* (webhooks) │ +│ │ │ +│ ▼ │ +│ error-handler middleware │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ + PostgreSQL (Drizzle) + │ + ▼ + enqueue work into reconcile_work_scope + │ + ▼ + workspace-engine picks it up +``` + +**Key design decisions:** + +- **REST surface is OpenAPI-first.** Paths and schemas live in `openapi/` as jsonnet, compile to `openapi.json`, and generate `src/types/openapi.ts`. `express-openapi-validator` enforces the spec at runtime; `AsyncTypedHandler` enforces it at compile time. +- **tRPC lives in `@ctrlplane/trpc`,** not here. This app only mounts the tRPC Express adapter. Adding a tRPC procedure means editing `packages/trpc`, not `apps/api`. +- **Auth is unified but dual-mode.** `requireAuth` middleware accepts either `X-API-Key` or a session cookie and populates `req.apiContext` with `{ db, authMethod, session, user }`. +- **The API never performs reconciliation.** It writes domain state and enqueues work. `workspace-engine` does the actual computation. + +## Directory Layout + +```text +apps/api/ +├── openapi/ # OpenAPI spec (jsonnet source → openapi.json) +│ ├── main.jsonnet # Entry point; imports paths/ and schemas/ +│ ├── paths/ # One file per resource (workspaces, deployments, …) +│ ├── schemas/ # Shared request/response schemas +│ ├── lib/ # Jsonnet helpers +│ └── openapi.json # Generated — do not edit by hand +└── src/ + ├── index.ts # Entry point (listens on PORT) + ├── server.ts # Express app wiring: middleware, routers + ├── auth.ts # Session helpers (getSession) + ├── config.ts # Env var parsing (@t3-oss/env-core + zod) + ├── client.ts # openapi-fetch client export (for other packages) + ├── middleware/ + │ ├── auth.ts # requireAuth, optionalAuth + │ └── error-handler.ts + ├── routes/ + │ ├── index.ts # Mounts /v1 sub-routers + │ ├── v1/ # REST (OpenAPI-validated) + │ │ └── workspaces/ # All workspace-scoped resources nest here + │ ├── github/ # GitHub webhook handlers + │ ├── tfe/ # Terraform Cloud webhook handlers + │ └── argoworkflow/ # Argo Workflow webhook handlers + └── types/ + ├── api.ts # AsyncTypedHandler, ApiContext, error classes + └── openapi.ts # Generated from openapi.json — do not edit +``` + +## Recipes + +### Add a REST endpoint + +REST endpoints are OpenAPI-first. The spec is the source of truth: if the handler's types don't match the spec, the build fails. + +**1. Describe the endpoint in jsonnet.** Find the right file in `openapi/paths/` (or create a new one and import it from `main.jsonnet`). For example, to add `POST /v1/workspaces/{workspaceId}/foos`: + +```jsonnet +// openapi/paths/foos.jsonnet +{ + '/v1/workspaces/{workspaceId}/foos': { + post: { + summary: 'Create a foo', + operationId: 'createFoo', + tags: ['Foos'], + parameters: [ + { name: 'workspaceId', 'in': 'path', required: true, + schema: { type: 'string', format: 'uuid' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { '$ref': '#/components/schemas/CreateFooRequest' }, + }, + }, + }, + responses: { + '201': { + description: 'Created', + content: { 'application/json': { + schema: { '$ref': '#/components/schemas/Foo' }, + } }, + }, + '401': { '$ref': '#/components/responses/Unauthorized' }, + '404': { '$ref': '#/components/responses/NotFound' }, + }, + }, + }, +} +``` + +Add the schemas it references in `openapi/schemas/`. + +**2. Regenerate the OpenAPI artifacts.** + +```bash +pnpm -F @ctrlplane/web-api generate +``` + +This runs `jsonnet openapi/main.jsonnet > openapi/openapi.json` and regenerates `src/types/openapi.ts` via `openapi-typescript`. Commit both. + +**3. Write the handler.** Handlers live next to their router, typed against the OpenAPI path + method: + +```ts +// src/routes/v1/workspaces/foos.ts +import type { AsyncTypedHandler } from "@/types/api.js"; +import { NotFoundError, asyncHandler } from "@/types/api.js"; +import { Router } from "express"; + +import { eq, takeFirst } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; + +const createFoo: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/foos", + "post" +> = async (req, res) => { + const { db, user } = req.apiContext!; + const { workspaceId } = req.params; + const { name } = req.body; + + const foo = await db + .insert(schema.foo) + .values({ workspaceId, name, createdBy: user.id }) + .returning() + .then(takeFirst); + + res.status(201).json(foo); +}; + +export const foosRouter = Router().post("/", asyncHandler(createFoo)); +``` + +Notes: + +- `req.apiContext!` — the non-null assertion is safe because `requireAuth` runs before `/v1` routers. The middleware guarantees it's populated. +- `req.body`, `req.params`, `req.query` are all strongly typed from the OpenAPI spec. +- `asyncHandler` wraps the handler so thrown errors reach the error middleware. + +**4. Mount the router.** In `src/routes/v1/workspaces/index.ts`: + +```ts +import { foosRouter } from "./foos.js"; + +// inside createWorkspacesRouter() +.use("/:workspaceId/foos", foosRouter) +``` + +**5. Write an e2e test.** See [Testing](#testing) below. + +### Add a webhook handler + +Webhooks skip the OpenAPI validator (see the `ignorePaths` regex in `server.ts`) because external systems control the payload shape. Each provider has its own router. + +```ts +// src/routes/github/webhooks/my-event.ts +import { Router } from "express"; +import { asyncHandler } from "@/types/api.js"; + +export const myEventRouter = Router().post("/", asyncHandler(async (req, res) => { + // 1. Verify the signature (provider-specific) + // 2. Parse and validate the payload with zod + // 3. Write domain state and enqueue reconciliation work + // 4. Return 200 quickly — providers retry on slow responses + res.status(200).send(); +})); +``` + +Signature verification is **mandatory** for public webhooks. GitHub uses `X-Hub-Signature-256`; look at existing handlers in `src/routes/github/` for the pattern. + +### Require authentication and check access + +Any route mounted under `/api/v1` is already authenticated — `requireAuth` populates `req.apiContext`. + +For **workspace access control**, check the user's role on the workspace. The pattern used throughout existing handlers: + +```ts +const { db, user } = req.apiContext!; +const isAdmin = user.systemRole === "admin"; + +const hasAccess = isAdmin + ? true + : await db + .select() + .from(entityRole) + .where(and( + eq(entityRole.scopeId, workspaceId), + eq(entityRole.scopeType, "workspace"), + eq(entityRole.entityId, user.id), + eq(entityRole.entityType, "user"), + )) + .limit(1) + .then((rows) => rows.length > 0); + +if (!hasAccess) throw new NotFoundError("Workspace not found"); +``` + +Return `404 Not Found` rather than `403 Forbidden` for workspaces the user can't see — it avoids leaking existence. + +### Enqueue reconciliation work + +When a write changes something that affects releases (a new version, a changed resource, a policy update), enqueue work for `workspace-engine` using the helpers in `@ctrlplane/db/reconcilers`: + +```ts +import { + enqueueManyDeploymentSelectorEval, + enqueueManyEnvironmentSelectorEval, + enqueueReleaseTargetsForResource, +} from "@ctrlplane/db/reconcilers"; + +// After upserting a resource: +await enqueueReleaseTargetsForResource(db, { workspaceId, resourceId }); + +// After changing a deployment selector: +await enqueueManyDeploymentSelectorEval(db, { deploymentIds }); +``` + +These write into `reconcile_work_scope`. `workspace-engine` leases and processes them asynchronously — your endpoint should not wait for the result. + +**Rule of thumb**: if the response semantics require the reconciliation to have completed, you're probably doing it wrong. Return `202 Accepted` and let the engine do its job. + +### Return a typed error + +Throw one of the error classes in `src/types/api.ts`; the error middleware converts them to HTTP responses: + +```ts +import { NotFoundError, BadRequestError, ForbiddenError, ApiError } from "@/types/api.js"; + +throw new NotFoundError("Foo not found"); +throw new BadRequestError("Invalid selector", { selector }); +throw new ApiError("Conflict", 409, "DUPLICATE_SLUG"); +``` + +Do **not** call `res.status(…).json(…)` for error cases in new handlers — throwing is the convention, and it keeps the response shape consistent. + +## Testing + +API tests live in `e2e/tests/api/` and use Playwright with an `openapi-fetch` client. There are no unit tests in `apps/api` — the e2e tests exercise the real Express app against a real database, which is what we care about. + +### Writing a test + +Import `test` from the fixtures file, which gives you an authenticated `api` client and a pre-seeded `workspace`: + +```ts +// e2e/tests/api/foos.spec.ts +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; + +import { test } from "../fixtures"; + +test.describe("Foos API", () => { + test("creates and retrieves a foo", async ({ api, workspace }) => { + const name = `foo-${faker.string.alphanumeric(6)}`; + + const createRes = await api.POST( + "/v1/workspaces/{workspaceId}/foos", + { + params: { path: { workspaceId: workspace.id } }, + body: { name }, + }, + ); + expect(createRes.response.status).toBe(201); + expect(createRes.data!.name).toBe(name); + + // Clean up + await api.DELETE( + "/v1/workspaces/{workspaceId}/foos/{fooId}", + { params: { path: { workspaceId: workspace.id, fooId: createRes.data!.id } } }, + ); + }); +}); +``` + +`api` is typed from the OpenAPI spec — path, params, body, and response are all inferred. If the spec and handler disagree, either the test or the build fails. + +### When to use YAML fixtures + +For tests that need a system + environments + resources + deployments + policies in a coherent configuration, use the YAML fixture loader instead of building entities by hand. See [e2e/README.md](../../e2e/README.md) for the full pattern and available template helpers. + +Pass `addRandomPrefix: true` when the same fixture is imported by tests that may run in parallel. + +### Running the tests + +```bash +cd e2e +pnpm exec playwright test tests/api/foos.spec.ts # one file +pnpm test:api # all API tests +pnpm test:debug # step through with the inspector +``` + +The e2e suite expects `pnpm dev` to be running (or a seeded workspace state at `.state/workspace.json`). See `e2e/README.md` for details. + +## Common Pitfalls + +- **Forgetting to regenerate `openapi.json`.** If you edited jsonnet but didn't run `pnpm -F @ctrlplane/web-api generate`, the validator will reject your request at runtime even though your handler's types look right. Always regenerate and commit both the jsonnet and the generated JSON/TS. +- **Skipping the OpenAPI spec for a new endpoint.** `express-openapi-validator` will 404 any path that isn't in the spec. Adding the handler without adding the spec leaves you with dead code. +- **Writing `res.status(400).json(…)` instead of throwing.** Inconsistent error shapes leak through. Throw one of the error classes and let the error middleware format the response. +- **Not scoping queries by `workspaceId`.** Nearly every table has a `workspaceId` column. Forgetting it is a cross-tenant data leak — treat it as a hard invariant. +- **Blocking on reconciliation.** If you find yourself polling for a release target to be computed before returning, you're fighting the architecture. Enqueue the work and return `202 Accepted`. +- **Testing behavior that depends on `workspace-engine`.** If an endpoint enqueues work, the test can only assert that the queue row was written, not that reconciliation has completed. For end-to-end behavior, write a Playwright test that waits for the observable outcome. +- **tRPC changes in the wrong repo.** tRPC routers live in `packages/trpc`, not here. If you need to add a procedure, that's where the change goes; `apps/api` just mounts the router. diff --git a/apps/web/CONTRIBUTING.md b/apps/web/CONTRIBUTING.md new file mode 100644 index 000000000..ddb5a53a6 --- /dev/null +++ b/apps/web/CONTRIBUTING.md @@ -0,0 +1,311 @@ +# Contributing to `apps/web` + +This is the service guide for `apps/web` — the React frontend. It assumes you've completed the root [CONTRIBUTING.md](../../CONTRIBUTING.md) setup and can run `pnpm dev`. The web app is served at **http://localhost:5173**. + +## Table of Contents + +- [Stack Overview](#stack-overview) +- [Directory Layout](#directory-layout) +- [Routing Conventions](#routing-conventions) +- [Recipes](#recipes) + - [Add a new page](#add-a-new-page) + - [Fetch data with tRPC](#fetch-data-with-trpc) + - [Call the REST API with openapi-fetch](#call-the-rest-api-with-openapi-fetch) + - [Build a form](#build-a-form) + - [Store state in the URL](#store-state-in-the-url) + - [Access the current workspace](#access-the-current-workspace) + - [Add a shadcn UI component](#add-a-shadcn-ui-component) +- [Styling](#styling) +- [Common Pitfalls](#common-pitfalls) + +## Stack Overview + +| Layer | Choice | +| ------------------ | -------------------------------------------------------------------------------------- | +| Framework | [React Router v7](https://reactrouter.com/) (framework mode, **client-only**, no SSR) | +| Build | [Vite](https://vitejs.dev/) + [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) | +| UI components | [shadcn/ui](https://ui.shadcn.com/) on top of [Radix](https://www.radix-ui.com/) | +| Styling | [Tailwind CSS v4](https://tailwindcss.com/) | +| Icons | [Lucide](https://lucide.dev/) (main) + [react-simple-icons](https://simpleicons.org/) | +| tRPC (primary API) | [`@trpc/react-query`](https://trpc.io/) with SuperJSON | +| REST (secondary) | [`openapi-fetch`](https://openapi-ts.pages.dev/openapi-fetch/) typed from `apps/api`'s OpenAPI | +| Auth client | [better-auth](https://www.better-auth.com/) (`authClient` singleton) | +| Forms | [react-hook-form](https://react-hook-form.com/) + [zod](https://zod.dev/) (`@hookform/resolvers`) | +| URL state | [nuqs](https://nuqs.47ng.com/) + React Router's `useSearchParams` | +| Graph viz | [ReactFlow](https://reactflow.dev/) + [dagre](https://github.com/dagrejs/dagre) layout | +| Code editor | [Monaco](https://microsoft.github.io/monaco-editor/) (for CEL selectors, JSON configs) | + +**Client-only, not SSR.** `react-router.config.ts` sets `{ ssr: false }` — every page renders in the browser. Don't reach for SSR-specific APIs; don't worry about hydration mismatches. + +## Directory Layout + +```text +apps/web/ +├── app/ +│ ├── root.tsx # HTML shell, providers (TRPCReactProvider, ThemeProvider) +│ ├── routes.ts # Explicit route tree — every route is registered here +│ ├── app.css # Tailwind entry +│ ├── api/ +│ │ ├── trpc.tsx # trpc client + TRPCReactProvider +│ │ ├── openapi-client.ts # openapi-fetch client factory +│ │ ├── openapi.ts # Generated from apps/api/openapi/openapi.json +│ │ └── auth-client.ts # better-auth client +│ ├── components/ +│ │ ├── ui/ # shadcn/ui primitives (button, dialog, form, …) +│ │ ├── WorkspaceProvider.tsx # useWorkspace() context +│ │ ├── ThemeProvider.tsx # dark/light mode +│ │ └── config-entry.tsx # shared app-level components +│ ├── hooks/ # Cross-cutting hooks (use-mobile, …) +│ ├── lib/ +│ │ └── utils.ts # cn() and other helpers +│ └── routes/ +│ ├── auth/ # /login, /sign-up (unauthenticated) +│ ├── protected.tsx # Auth gate — every route below requires a session +│ ├── workspaces/ # /workspaces/create +│ └── ws/ # /:workspaceSlug/* — the main app +│ ├── _layout.tsx # Sidebar, topbar, workspace switcher +│ ├── _components/ # Layout-level shared components +│ ├── deployments/ # Route + sub-routes for /:ws/deployments/* +│ ├── environments/ +│ ├── resources/ +│ ├── … +│ └── settings/ +├── public/ +├── react-router.config.ts +├── vite.config.ts +├── tailwind.config.ts +└── components.json # shadcn/ui config (where `pnpm ui-add` writes) +``` + +**Import alias**: `~/` → `app/`. Use it for everything in-app; only use relative paths for truly co-located files in the same directory. + +## Routing Conventions + +Routes are declared **explicitly** in `app/routes.ts`. There's no file-based routing magic — if a file isn't listed in `routes.ts`, it's not a route. + +- **`_layout.tsx`** — a layout route. Renders an `` for children; does not show as a page on its own. Use when multiple sibling routes share a shell (sidebar, tabs, etc.). +- **Underscore-prefixed folders** like `_components/` and `_hooks/` — not routes. Safe locations for co-located components and hooks specific to the feature. +- **`page.$paramName.tsx`** — convention for pages with a URL parameter (e.g. `page.$deploymentId.tsx` for `/:deploymentId`). The actual URL segment comes from the string passed to `route(...)` in `routes.ts`; the filename is just a convention. +- **Auth gating**: `protected.tsx` wraps every authenticated route. It calls `trpc.user.session.useQuery()`, redirects to `/login` if unauthenticated, and handles workspace resolution before rendering children. + +## Recipes + +### Add a new page + +Suppose you're adding `/:workspaceSlug/foos`. + +**1. Create the route component.** Default-export a React component from `app/routes/ws/foos.tsx`: + +```tsx +// app/routes/ws/foos.tsx +import { useWorkspace } from "~/components/WorkspaceProvider"; +import { trpc } from "~/api/trpc"; + +export function meta() { + return [{ title: "Foos - Ctrlplane" }]; +} + +export default function FoosPage() { + const { workspace } = useWorkspace(); + const { data: foos, isLoading } = trpc.foo.list.useQuery({ + workspaceId: workspace.id, + }); + + if (isLoading) return
Loading…
; + return ( +
+

Foos

+
    {foos?.map((f) =>
  • {f.name}
  • )}
+
+ ); +} +``` + +**2. Register it in `routes.ts`** — inside the `ws/_layout.tsx` children: + +```ts +route(":workspaceSlug", "routes/ws/_layout.tsx", [ + // …existing routes + route("foos", "routes/ws/foos.tsx"), +]), +``` + +**3. Add a sidebar link** in `app/routes/ws/_layout.tsx` if the page should be user-discoverable. + +If the page has sub-routes (e.g. `/foos/:id`, `/foos/:id/settings`), add a sibling `route("foos", "routes/ws/foos/_layout.tsx", [...])` entry — see the `deployments` tree for the pattern. + +### Fetch data with tRPC + +The main data layer is tRPC. Procedures live in `packages/trpc`; the client is imported from `~/api/trpc`: + +```tsx +import { trpc } from "~/api/trpc"; + +const { data, isLoading, error } = trpc.deployment.list.useQuery({ + workspaceId: workspace.id, +}); + +const createDeployment = trpc.deployment.create.useMutation({ + onSuccess: () => { + // invalidate cached queries on success + utils.deployment.list.invalidate(); + }, +}); + +createDeployment.mutate({ name: "api", workspaceId: workspace.id }); +``` + +**Cache invalidation** uses `trpc.useUtils()`: + +```tsx +const utils = trpc.useUtils(); +// later… +await utils.deployment.list.invalidate({ workspaceId: workspace.id }); +``` + +The default `staleTime` is 5 seconds (see `createQueryClient` in `app/api/trpc.tsx`), so refetches happen aggressively. Bump `staleTime` in individual queries if you're displaying data that doesn't need to be instantly fresh. + +**To add a new procedure**, that's a change to `packages/trpc` — not to this app. The client picks it up automatically via the `AppRouter` type import. + +### Call the REST API with openapi-fetch + +For endpoints that exist only on REST (not tRPC), use the typed `openapi-fetch` client: + +```tsx +import { createClient } from "~/api/openapi-client"; + +const api = createClient({ baseUrl: "/api" }); + +const { data, error } = await api.GET( + "/v1/workspaces/{workspaceId}/resources", + { params: { path: { workspaceId: workspace.id } } }, +); +``` + +Path, params, body, and response are all typed from `app/api/openapi.ts`, which is generated from `apps/api/openapi/openapi.json`. + +**When the REST API spec changes**, regenerate the types: + +```bash +pnpm -F @ctrlplane/web generate +``` + +This runs `openapi-typescript` against the API's `openapi.json`. Commit the regenerated `app/api/openapi.ts`. + +### Build a form + +Use `react-hook-form` + `zod` + the shadcn `Form` component for any form with more than a couple of fields: + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, FormField, FormItem, FormLabel, FormControl, FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + slug: z.string().regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, hyphens only"), +}); + +export function CreateFooForm({ onSubmit }: { onSubmit: (values: z.infer) => void }) { + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { name: "", slug: "" }, + }); + + return ( +
+ + ( + + Name + + + + )} + /> + + + + ); +} +``` + +Validation lives in the zod schema; the UI rendering lives in `FormField`. Don't manually manage `useState` for form fields. + +### Store state in the URL + +For state that should survive a refresh or be shareable via URL (filters, selected tab, pagination), use the URL as the source of truth. + +**Simple string params** — React Router's `useSearchParams`: + +```tsx +import { useSearchParams } from "react-router"; + +const [searchParams, setSearchParams] = useSearchParams(); +const filter = searchParams.get("filter") ?? "all"; +``` + +**Typed params** — [nuqs](https://nuqs.47ng.com/) (already wired up in `root.tsx`): + +```tsx +import { useQueryState, parseAsStringEnum } from "nuqs"; + +const [status, setStatus] = useQueryState( + "status", + parseAsStringEnum(["all", "active", "archived"]).withDefault("all"), +); +``` + +Use nuqs when you want parsed/typed values or defaults; use `useSearchParams` for quick string flags. + +### Access the current workspace + +Anything inside `/:workspaceSlug/...` is wrapped by `WorkspaceProvider` (set up in `protected.tsx`). Read it with: + +```tsx +import { useWorkspace } from "~/components/WorkspaceProvider"; + +const { workspace } = useWorkspace(); +// workspace: { id, name, slug } +``` + +This throws if called outside the provider — which is the desired behavior. Don't render workspace-scoped components outside the `ws/` route tree. + +### Add a shadcn UI component + +shadcn components are **copied into the repo** (not installed as a dependency). To add one: + +```bash +pnpm ui-add +# e.g. pnpm ui-add toggle-group +``` + +This writes the component to `app/components/ui/`. Tweak it freely — it's your code now. Don't edit generated shadcn primitives to contain feature logic, though; compose them in feature-level components instead. + +Existing primitives to check before adding new ones: button, input, select, dialog, sheet, dropdown-menu, command, form, table, tabs, popover, tooltip, toast (`sonner`), and the others listed in `components/ui/`. + +## Styling + +- **Tailwind v4**. Use utility classes. Use `cn()` from `~/lib/utils` to compose conditional classes. +- **Dark mode**: `ThemeProvider` defaults to dark. Pages should style both themes — use `bg-background`, `text-foreground`, etc. (Tailwind CSS variables), not hardcoded `bg-white` / `text-black`. +- **Spacing/sizing**: prefer Tailwind scale (`p-4`, `gap-2`, `h-8`) over arbitrary values. +- **No CSS-in-JS**. No stylesheets per component. If you need something Tailwind can't express, add it to `app.css`. + +## Common Pitfalls + +- **Forgetting to add the route to `routes.ts`.** Dropping a file in `app/routes/` does nothing on its own — the route tree is explicit. If your page 404s, check `routes.ts` first. +- **Stale REST types after an API change.** If you edited `apps/api/openapi/` and your `openapi-fetch` calls are red-squiggled, run `pnpm -F @ctrlplane/web generate`. tRPC types propagate automatically; REST types need regeneration. +- **Calling `useWorkspace()` outside the `ws/` tree.** The provider isn't mounted on `/login`, `/sign-up`, or `/workspaces/create` — calling the hook there throws. Gate component rendering on route. +- **Writing feature logic inside `components/ui/*.tsx`.** Those are shadcn primitives; keep them generic. Feature logic belongs in route files or feature-level components (e.g. `routes/ws/deployments/_components/…`). +- **Assuming SSR.** Don't use `typeof window === "undefined"` guards; we're client-only. Don't fetch data in a loader/action — use tRPC/openapi-fetch from inside components. +- **Not wrapping async mutations with toast feedback.** Use `sonner` (already wired in `root.tsx`) to give users feedback on mutations: `toast.success("Deployment created")` / `toast.error(...)`. Silent mutations feel broken. +- **Over-using React state for URL-worthy data.** Filters, tab selections, and open-panel state should live in the URL (nuqs or searchParams), not `useState`. Refreshing the page shouldn't reset the user's context. +- **Editing `app/api/openapi.ts` by hand.** It's generated; your edits will vanish on the next `generate`. diff --git a/apps/workspace-engine/CONTRIBUTING.md b/apps/workspace-engine/CONTRIBUTING.md new file mode 100644 index 000000000..dcc61f7be --- /dev/null +++ b/apps/workspace-engine/CONTRIBUTING.md @@ -0,0 +1,437 @@ +# Contributing to `apps/workspace-engine` + +This is the service guide for `apps/workspace-engine` — the Go reconciliation engine that turns user intent (a new version, a changed selector, a policy update) into concrete jobs dispatched to job agents. It assumes you've completed the root [CONTRIBUTING.md](../../CONTRIBUTING.md) setup and can run `pnpm dev` (or `go run .`) in this directory. + +## Table of Contents + +- [What This Service Does](#what-this-service-does) +- [Architecture](#architecture) +- [Directory Layout](#directory-layout) +- [Recipes](#recipes) + - [Add a new controller](#add-a-new-controller) + - [Enqueue work from another controller](#enqueue-work-from-another-controller) + - [Use `RequeueAfter` for scheduled retries](#use-requeueafter-for-scheduled-retries) + - [Write a unit test with mocks](#write-a-unit-test-with-mocks) +- [Running a Subset of Controllers Locally](#running-a-subset-of-controllers-locally) +- [Common Pitfalls](#common-pitfalls) + +## What This Service Does + +`apps/workspace-engine` is the **asynchronous brain** behind Ctrlplane. The API records what the user wants (state in Postgres) and enqueues a work item; this service picks up that item, computes the consequences, and either writes derived state back or enqueues further work for the next controller. + +Controllers are grouped by concern. Roughly, work flows top-to-bottom: selectors decide which resources are in scope, planning (including policy evaluation) decides what gets released where, and the job execution group runs and verifies the resulting jobs. + +### Selector & relationship evaluation + +Decide **which resources belong where** — recomputed whenever a selector or relationship rule changes, or when a resource's metadata changes. + +| Controller | Responsibility | +| --------------------------------- | ----------------------------------------------------------- | +| `deploymentresourceselectoreval` | Recompute which resources match a deployment's selector | +| `environmentresourceselectoreval` | Recompute which resources match an environment's selector | +| `relationshipeval` | Evaluate resource relationship rules | + +### Release planning + +Turn intent (a new version, a changed selector, a policy update) into **which release should be deployed to each release target**. Policy evaluation lives here because policies determine *what* gets released — approvals, environment progression, rollout sequencing — not whether a specific job can run. + +| Controller | Responsibility | +| ---------------------- | ------------------------------------------------------------------ | +| `deploymentplan` | Fan a deployment plan into per-(environment, resource, agent) work | +| `deploymentplanresult` | Materialize the final release row from a plan result | +| `desiredrelease` | Pick the target version for each release target | +| `policyeval` | Evaluate policy rules (approvals, progression, windows, rollout) | +| `forcedeploy` | Handle manual force-deploy requests | + +### Job execution & verification + +Once a release is decided, **run the job and track its outcome**. + +| Controller | Responsibility | +| ----------------------- | ------------------------------------------------------- | +| `jobeligibility` | Check whether a job is ready to run | +| `jobdispatch` | Route an eligible job to the right job agent | +| `jobverificationmetric` | Poll verification metrics (Datadog, Prometheus, HTTP) | + +The engine is **horizontally scalable** — every controller is a standalone worker, multiple instances can run simultaneously, and lease-based locking in the queue prevents duplicate processing. + +## Architecture + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ apps/workspace-engine │ +│ │ +│ main.go │ +│ │ │ +│ └─► svc.Runner — manages lifecycle (start / signal / stop) │ +│ │ │ +│ ├─► pprof, HTTP server, claim cleanup │ +│ └─► controllers (each is a svc.Service wrapping a Worker) │ +│ │ +│ ┌────────────────── Worker ──────────────────┐ │ +│ │ 1. Claim(batch) from queue │ │ +│ │ 2. For each item, spawn goroutine: │ │ +│ │ - start lease heartbeat │ │ +│ │ - call processor.Process(ctx, item) │ │ +│ │ - AckSuccess / Retry (with backoff) │ │ +│ │ - if RequeueAfter > 0: re-enqueue │ │ +│ └────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ reconcile.Processor │ +│ │ │ +│ ┌────────────────┴────────────────┐ │ +│ │ Controller (per kind) │ │ +│ │ - Getter interface │ │ +│ │ - Setter interface │ │ +│ │ - Process(ctx, item) Result │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + ▲ + │ + ┌─────────┴─────────┐ + │ Postgres queue │ + │ reconcile_work_ │ + │ scope │ + └───────────────────┘ +``` + +**Key design decisions:** + +- **Controllers are independent.** Each controller claims from one kind of work and writes its output either to domain tables or as enqueues for the next kind. No direct controller-to-controller calls. +- **Dependency injection via `Getter` / `Setter` interfaces.** Every controller defines `Getter` (reads) and `Setter` (writes) interfaces, with a `*Postgres` implementation for production and mocks for tests. This lets us unit-test controllers without a real database. +- **Lease-based locking.** When a worker claims an item, it holds a lease that it heartbeats periodically. If the worker crashes, the lease expires and another worker picks the item up. Duplicate processing is prevented without distributed locks. +- **`Result.RequeueAfter`** lets a controller ask to be run again later (e.g. polling a verification metric every 30s) without manual enqueue. +- **No controller-to-agent communication in-process.** Job agents (GitHub Actions, ArgoCD, etc.) are reached via the external APIs they expose; this service never holds long-lived connections to them. + +## Directory Layout + +```text +apps/workspace-engine/ +├── main.go # Entry point — registers every controller +├── otel.go # OpenTelemetry setup +├── go.mod / go.sum +├── oapi/ # OpenAPI spec inputs (spec/, cfg.yaml) + generated openapi.json +├── pkg/ +│ ├── config/ # env var config (incl. SERVICES filter) +│ ├── db/ # pgx pool, sqlc-generated queries +│ ├── oapi/ # Generated Go types (oapi.gen.go) + domain helpers +│ ├── reconcile/ # Work queue + worker loop (generic, reusable) +│ │ ├── workqueue.go # Queue interface, Item, EnqueueParams, … +│ │ ├── worker.go # Claim → process → ack/retry loop +│ │ ├── events/ # Per-kind Kind constants + Enqueue helpers +│ │ ├── postgres/ # Queue impl backed by reconcile_work_scope +│ │ └── memory/ # In-memory queue impl (for tests) +│ ├── selector/ # CEL selector evaluation +│ ├── policies/ # Policy rule evaluation +│ ├── store/ # In-memory snapshots / caches +│ └── jobagents/ # Job agent adapters (GHA, Argo, TFC, K8s, …) +└── svc/ + ├── service.go # svc.Service interface + svc.Runner + ├── http/ # Admin / debug HTTP server + ├── pprof/ # pprof endpoint + ├── claimcleanup/ # Periodic cleanup of expired leases + └── controllers/ # One directory per controller + └── / + ├── controller.go # Process() implementation + ├── controller_test.go # Unit tests with mocks + ├── getters.go # Getter interface definition + ├── getters_postgres.go # Postgres impl + ├── setters.go # Setter interface definition + └── setters_postgres.go # Postgres impl +``` + +## Recipes + +### Add a new controller + +Adding a controller is the most common substantial change. The pattern has six pieces — mirror what `deploymentplan` already does. + +**1. Declare the kind.** In `pkg/reconcile/events/`, add a file for your kind: + +```go +// pkg/reconcile/events/myfeature.go +package events + +import ( + "context" + "workspace-engine/pkg/reconcile" +) + +const MyFeatureKind = "my-feature" + +type MyFeatureParams struct { + WorkspaceID string + TargetID string +} + +func EnqueueMyFeature(queue reconcile.Queue, ctx context.Context, params MyFeatureParams) error { + return queue.Enqueue(ctx, reconcile.EnqueueParams{ + WorkspaceID: params.WorkspaceID, + Kind: MyFeatureKind, + ScopeType: "my-feature", + ScopeID: params.TargetID, + }) +} +``` + +The `Kind` string is what the worker polls for; `ScopeType` + `ScopeID` identify the entity being reconciled. + +**2. Define the `Getter` / `Setter` interfaces.** Create a new controller directory and split read/write concerns: + +```go +// svc/controllers/myfeature/getters.go +package myfeature + +import ( + "context" + "github.com/google/uuid" + "workspace-engine/pkg/oapi" +) + +type Getter interface { + GetTarget(ctx context.Context, id uuid.UUID) (*oapi.MyTarget, error) +} +``` + +```go +// svc/controllers/myfeature/setters.go +package myfeature + +import "context" + +type Setter interface { + MarkTargetProcessed(ctx context.Context, id string) error +} +``` + +**3. Implement the Postgres getters/setters.** Put the production versions in `getters_postgres.go` / `setters_postgres.go`. These wrap `db.GetQueries(ctx)` (sqlc-generated) and implement the interfaces above. + +**4. Write the `Controller`.** Implement `reconcile.Processor`: + +```go +// svc/controllers/myfeature/controller.go +package myfeature + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "workspace-engine/pkg/reconcile" +) + +var tracer = otel.Tracer("workspace-engine/svc/controllers/myfeature") + +var _ reconcile.Processor = (*Controller)(nil) + +type Controller struct { + getter Getter + setter Setter +} + +func NewController(getter Getter, setter Setter) *Controller { + return &Controller{getter: getter, setter: setter} +} + +func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcile.Result, error) { + ctx, span := tracer.Start(ctx, "myfeature.Controller.Process") + defer span.End() + + targetID, err := uuid.Parse(item.ScopeID) + if err != nil { + return reconcile.Result{}, fmt.Errorf("parse target id: %w", err) + } + + target, err := c.getter.GetTarget(ctx, targetID) + if err != nil { + return reconcile.Result{}, fmt.Errorf("get target: %w", err) + } + + // ... do the work ... + + if err := c.setter.MarkTargetProcessed(ctx, item.ScopeID); err != nil { + return reconcile.Result{}, fmt.Errorf("mark processed: %w", err) + } + + return reconcile.Result{}, nil +} +``` + +**5. Wire up the `svc.Service` factory.** Add a `New(workerID, pgxPool)` function: + +```go +// svc/controllers/myfeature/controller.go (continued) +import ( + "time" + "github.com/charmbracelet/log" + "github.com/jackc/pgx/v5/pgxpool" + "workspace-engine/pkg/config" + "workspace-engine/pkg/reconcile/events" + "workspace-engine/pkg/reconcile/postgres" + "workspace-engine/svc" +) + +func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { + kind := events.MyFeatureKind + nodeConfig := reconcile.NodeConfig{ + WorkerID: workerID, + BatchSize: 10, + PollInterval: 1 * time.Second, + LeaseDuration: 30 * time.Second, + LeaseHeartbeat: 15 * time.Second, + MaxConcurrency: config.GetMaxConcurrency(kind), + MaxRetryBackoff: 10 * time.Second, + } + queue := postgres.NewForKinds(pgxPool, kind) + + controller := &Controller{ + getter: &PostgresGetter{}, + setter: &PostgresSetter{}, + } + + worker, err := reconcile.NewWorker(kind, queue, controller, nodeConfig) + if err != nil { + log.Fatal("failed to create myfeature worker", "error", err) + } + return worker +} +``` + +**6. Register it in `main.go`.** Add the import and the constructor call to the `allServices` slice: + +```go +import "workspace-engine/svc/controllers/myfeature" + +// inside main() +allServices := []svc.Service{ + // ... + myfeature.New(WorkerID, db.GetPool(ctx)), +} +``` + +The `SERVICES` env var will now include `my-feature` as a toggleable kind. + +### Enqueue work from another controller + +When controller A produces work for controller B, A's `Setter` enqueues into B's kind. The pattern: + +```go +// In the setter implementation for controller A +func (s *PostgresSetter) enqueueFollowUp(ctx context.Context, workspaceID, targetID string) error { + return events.EnqueueMyFeature(s.queue, ctx, events.MyFeatureParams{ + WorkspaceID: workspaceID, + TargetID: targetID, + }) +} +``` + +Hold a `reconcile.Queue` on the setter (see `deploymentplan`'s `PostgresSetter` for the reference pattern — it takes the shared queue in the constructor). + +### Use `RequeueAfter` for scheduled retries + +For controllers that need to poll something (verification metrics, external API state), return a non-zero `RequeueAfter`: + +```go +func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcile.Result, error) { + status, err := c.getter.CheckMetric(ctx, item.ScopeID) + if err != nil { + return reconcile.Result{}, err // normal error → exponential backoff retry + } + + if status == StatusPending { + return reconcile.Result{RequeueAfter: 30 * time.Second}, nil + } + + // Terminal state — don't requeue + return reconcile.Result{}, nil +} +``` + +`RequeueAfter` is different from returning an error: the item is **acked as successful**, then a new item is enqueued with `NotBefore = now + RequeueAfter`. No attempt counter increment, no exponential backoff. + +### Write a unit test with mocks + +Every controller should have a `controller_test.go` that exercises `Process()` against mock `Getter` / `Setter` implementations. The pattern used throughout the codebase: + +```go +// svc/controllers/myfeature/controller_test.go +package myfeature + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "workspace-engine/pkg/reconcile" +) + +type mockGetter struct { + target *oapi.MyTarget + targetErr error +} + +func (m *mockGetter) GetTarget(_ context.Context, _ uuid.UUID) (*oapi.MyTarget, error) { + return m.target, m.targetErr +} + +type mockSetter struct { + processedIDs []string + err error +} + +func (m *mockSetter) MarkTargetProcessed(_ context.Context, id string) error { + if m.err != nil { + return m.err + } + m.processedIDs = append(m.processedIDs, id) + return nil +} + +func TestProcess_HappyPath(t *testing.T) { + targetID := uuid.New() + getter := &mockGetter{target: &oapi.MyTarget{ID: targetID}} + setter := &mockSetter{} + + c := NewController(getter, setter) + _, err := c.Process(context.Background(), reconcile.Item{ + ScopeID: targetID.String(), + }) + + require.NoError(t, err) + require.Equal(t, []string{targetID.String()}, setter.processedIDs) +} +``` + +Use `testify/require` when a failure should abort the test (setup invariants) and `testify/assert` when the test should continue to check other conditions. Prefer **table-driven tests** when you have more than two or three scenarios — see `deploymentplan/controller_test.go` for a fully worked example. + +## Running a Subset of Controllers Locally + +Controllers are expensive — running all of them locally spins up N polling goroutines against Postgres. While developing one, set `SERVICES` in `.env` to just the kinds you need: + +```bash +# Only the two you're iterating on +SERVICES=deployment-plan,policy-eval +``` + +`IsServiceEnabled` does an exact string match against the `Kind` constants in `pkg/reconcile/events/` — they're hyphenated (`deployment-plan`, `policy-eval`, `job-dispatch`, `desired-release`, `relationship-eval`, `force-deploy`, `deployment-resource-selector-eval`, `environment-resource-selector-eval`, `deployment-plan-target-result`, `job-eligibility`, `job-verification-metric`). Mismatched names silently skip the controller — check `pkg/reconcile/events/*.go` if you're unsure. + +Use [air](https://github.com/cosmtrek/air) for hot reload — `.air.toml` is already configured: + +```bash +pnpm -F @ctrlplane/workspace-engine dev # runs `air` +``` + +For debugging, the pprof server is available at `http://localhost:6060/debug/pprof/` by default. + +## Common Pitfalls + +- **Blocking work on the queue poll path.** `Process()` runs in its own goroutine with a lease heartbeat, so blocking is fine — **don't** sleep or block in getter/setter constructors, which run at startup. +- **Forgetting to register the controller in `main.go`.** The controller's package compiles fine in isolation, but if it's not in `allServices`, it never runs. Symptom: work piles up in `reconcile_work_scope` with no worker claiming it. +- **Returning an error when you meant `RequeueAfter`.** Errors go through exponential backoff and increment the attempt counter. Polling situations (verification, external state) should return `Result{RequeueAfter: …}` with `nil` error so the item isn't treated as a failure. +- **Using `context.Background()` in production paths.** The `ctx` passed to `Process()` carries tracing and cancellation. Propagate it to every getter/setter call. Only use `context.Background()` in `New(…)` startup wiring (e.g. the one-time `db.GetQueries` call). +- **Leaking the lease.** Don't spawn goroutines from `Process()` that outlive the function return — the lease heartbeat stops when `Process()` returns, and any work still running loses its claim. If you need fan-out, either wait for all child goroutines before returning, or enqueue them as separate work items. +- **Tight coupling between controllers.** Controller A should never import controller B's types. They communicate only through the queue (and the domain tables). If two controllers need to share logic, lift it into `pkg/`. +- **Postgres getters without workspace scoping.** Every query should include `workspace_id` in its `WHERE` clause — the queue items carry `WorkspaceID` for exactly this reason. Missing it is a cross-tenant data leak. +- **Racing `SERVICES` with a controller someone else depends on.** If you run only `deployment-plan` locally but not `deployment-plan-result`, your plan output will sit in the queue forever. Check what enqueues what before narrowing `SERVICES`.