From 8501a37950a3c7ee4ce8a5ce651006bc42c1aefe Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:22:24 +0000 Subject: [PATCH 001/249] Go: foundation for the single keel binary Root module github.com/ThallesP/keel: domain types and rules ported from convex/schema.ts and status.ts, app ports and access rules, SQLite store (modernc + sqlc, embedded migrations), Huma HTTP transport with problem errors carrying the CLI's codes, keel serve and keel openapi. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/keel/main.go | 19 + docs/go/ARCHITECTURE.md | 158 +++++ go.mod | 24 + go.sum | 79 +++ internal/adapters/sqlite/auth.go | 3 + internal/adapters/sqlite/canvas.go | 3 + internal/adapters/sqlite/core.go | 261 ++++++++ internal/adapters/sqlite/core_test.go | 101 +++ internal/adapters/sqlite/db/core.sql.go | 585 ++++++++++++++++++ internal/adapters/sqlite/db/db.go | 31 + internal/adapters/sqlite/db/models.go | 214 +++++++ internal/adapters/sqlite/deploy.go | 3 + internal/adapters/sqlite/ingress.go | 3 + .../adapters/sqlite/migrations/0001_init.sql | 254 ++++++++ internal/adapters/sqlite/observability.go | 3 + internal/adapters/sqlite/queries/core.sql | 82 +++ internal/adapters/sqlite/sqlc.yaml | 12 + internal/adapters/sqlite/store.go | 189 ++++++ internal/api/api.go | 41 ++ internal/app/access.go | 102 +++ internal/app/app.go | 72 +++ internal/app/auth.go | 13 + internal/app/changes.go | 66 ++ internal/app/ports.go | 66 ++ internal/app/ports_auth.go | 5 + internal/app/ports_canvas.go | 5 + internal/app/ports_deploy.go | 8 + internal/app/ports_ingress.go | 8 + internal/app/ports_observability.go | 8 + internal/cli/root.go | 61 ++ internal/cli/web.go | 6 + internal/domain/actor.go | 36 ++ internal/domain/auth.go | 61 ++ internal/domain/deployment.go | 55 ++ internal/domain/endpoint.go | 40 ++ internal/domain/errors.go | 71 +++ internal/domain/ids.go | 29 + internal/domain/node.go | 103 +++ internal/domain/project.go | 18 + internal/domain/status.go | 76 +++ internal/domain/status_test.go | 38 ++ internal/domain/validate.go | 72 +++ internal/serve/serve.go | 92 +++ internal/transport/http/auth.go | 6 + internal/transport/http/canvas.go | 6 + internal/transport/http/deploy.go | 6 + internal/transport/http/deploy_raw.go | 6 + internal/transport/http/ingress.go | 6 + internal/transport/http/ingress_raw.go | 6 + internal/transport/http/meta.go | 21 + internal/transport/http/observability.go | 6 + internal/transport/http/observability_raw.go | 6 + internal/transport/http/server.go | 248 ++++++++ internal/transport/http/util.go | 30 + 54 files changed, 3523 insertions(+) create mode 100644 cmd/keel/main.go create mode 100644 docs/go/ARCHITECTURE.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/adapters/sqlite/auth.go create mode 100644 internal/adapters/sqlite/canvas.go create mode 100644 internal/adapters/sqlite/core.go create mode 100644 internal/adapters/sqlite/core_test.go create mode 100644 internal/adapters/sqlite/db/core.sql.go create mode 100644 internal/adapters/sqlite/db/db.go create mode 100644 internal/adapters/sqlite/db/models.go create mode 100644 internal/adapters/sqlite/deploy.go create mode 100644 internal/adapters/sqlite/ingress.go create mode 100644 internal/adapters/sqlite/migrations/0001_init.sql create mode 100644 internal/adapters/sqlite/observability.go create mode 100644 internal/adapters/sqlite/queries/core.sql create mode 100644 internal/adapters/sqlite/sqlc.yaml create mode 100644 internal/adapters/sqlite/store.go create mode 100644 internal/api/api.go create mode 100644 internal/app/access.go create mode 100644 internal/app/app.go create mode 100644 internal/app/auth.go create mode 100644 internal/app/changes.go create mode 100644 internal/app/ports.go create mode 100644 internal/app/ports_auth.go create mode 100644 internal/app/ports_canvas.go create mode 100644 internal/app/ports_deploy.go create mode 100644 internal/app/ports_ingress.go create mode 100644 internal/app/ports_observability.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/web.go create mode 100644 internal/domain/actor.go create mode 100644 internal/domain/auth.go create mode 100644 internal/domain/deployment.go create mode 100644 internal/domain/endpoint.go create mode 100644 internal/domain/errors.go create mode 100644 internal/domain/ids.go create mode 100644 internal/domain/node.go create mode 100644 internal/domain/project.go create mode 100644 internal/domain/status.go create mode 100644 internal/domain/status_test.go create mode 100644 internal/domain/validate.go create mode 100644 internal/serve/serve.go create mode 100644 internal/transport/http/auth.go create mode 100644 internal/transport/http/canvas.go create mode 100644 internal/transport/http/deploy.go create mode 100644 internal/transport/http/deploy_raw.go create mode 100644 internal/transport/http/ingress.go create mode 100644 internal/transport/http/ingress_raw.go create mode 100644 internal/transport/http/meta.go create mode 100644 internal/transport/http/observability.go create mode 100644 internal/transport/http/observability_raw.go create mode 100644 internal/transport/http/server.go create mode 100644 internal/transport/http/util.go diff --git a/cmd/keel/main.go b/cmd/keel/main.go new file mode 100644 index 0000000..94e924b --- /dev/null +++ b/cmd/keel/main.go @@ -0,0 +1,19 @@ +// Command keel is Keel: control plane (serve), edge proxy (proxy), node agent (agent) and the CLI, +// in one binary. See docs/go/ARCHITECTURE.md. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/ThallesP/keel/internal/cli" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := cli.Execute(ctx) + stop() + os.Exit(code) +} diff --git a/docs/go/ARCHITECTURE.md b/docs/go/ARCHITECTURE.md new file mode 100644 index 0000000..ad53c93 --- /dev/null +++ b/docs/go/ARCHITECTURE.md @@ -0,0 +1,158 @@ +# Keel in Go — architecture contract + +One Go module at the repo root (`github.com/ThallesP/keel`), one binary `keel`. This file is the +contract every package follows. The porting specs it implements live in `docs/go/spec/`. + +## The binary + +| Command | Runs where | What it is | +| ------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `keel serve` | control plane, container `keel` (compose) | HTTP API + WebSocket + embedded dashboard + SQLite + Swarm driver (Docker socket) + jobs | +| `keel proxy` | control plane, container `proxy` (compose) | Embedded Caddy + caddy-l4 + the keel plugin (was `apps/proxy`). No Docker socket, ever. | +| `keel agent` | every Swarm node, global service `keel-agent` | Docker events forwarder + log shipper (was `apps/worker`). Read-only Docker socket. | +| `keel openapi` | build time | Prints the OpenAPI 3.1 document (`openapi.json` at the repo root is its committed output). | +| `keel login`, `ship`, … | anyone's laptop / agent | The CLI (was `apps/cli`), contract unchanged: `apps/cli/README.md` → `docs/cli.md`. | + +`serve` and `proxy` are the same binary in two containers on purpose: the process that parses +internet traffic never holds the Docker socket (root on the host). Do not merge them. + +## Layers and the dependency rule + +``` +internal/ + domain/ entities, value objects, pure rules. Imports nothing from this repo. No I/O. + app/ use cases. Imports domain. Defines the ports (interfaces) it needs. + adapters/ implementations of app ports: sqlite, swarm (Docker SDK), caddy (proxy admin + client), axiom, realtime (centrifuge), jobs (in-memory scheduler). + transport/http Huma handlers + raw routes (worker, otlp, ws, static). Calls app. Never touches + adapters directly. + api/ wire types (request/response bodies, error codes). Shared by transport/http and + the CLI client. Imports nothing but stdlib. + serve/ wiring for `keel serve`: config from env, construct adapters, inject into app. + agent/ `keel agent` (node side). Talks to serve over HTTP only. + proxy/ `keel proxy`: embedded Caddy + plugin modules. + cli/ CLI commands, config, output, HTTP client (uses api/). +cmd/keel/ main.go → cli.Execute() +``` + +- `domain` never imports `app`, `adapters`, `transport`, `api`. +- `app` never imports `adapters`, `transport`, `api`, `net/http`, `database/sql`, Docker SDK. +- Only `adapters/sqlite` imports a SQL driver. Only `adapters/swarm` and `agent` import the Docker SDK. +- Interfaces are defined by the consumer (in `app`), small, one file per area. + +## Data + +- SQLite, pure Go driver `modernc.org/sqlite`, WAL, `foreign_keys=ON`, `busy_timeout=5000`. + One write connection (writes serialize, `BEGIN IMMEDIATE`), a read pool. File: `$KEEL_DATA_DIR/keel.db`. +- Schema: `internal/adapters/sqlite/migrations/NNNN_name.sql`, applied in order at start, tracked + in `schema_migrations`. Additive only once released. +- Queries: `sqlc` (`internal/adapters/sqlite/sqlc.yaml`), one `queries/.sql` per area, + generated into `internal/adapters/sqlite/db/`. Run `sqlc generate` from `internal/adapters/sqlite`. +- IDs: `domain.NewID()`: 20 random lowercase base32 chars. Strings everywhere. +- Times: unix milliseconds (`int64`) in the database, the domain and the JSON API (the dashboard + compares them with `Date.now()`). The CLI prints RFC 3339. +- Nested Convex objects become columns (`desired_image`, `observed_state`, …) or child tables + (`endpoints`, `deployment_steps`, `deployment_log`). JSON columns only for small free-form + lists (`observed_node_ids`). + +## Use cases + +```go +// app.App holds every port. One file per area: app/projects.go, app/nodes.go, … +func (a *App) RenameNode(ctx context.Context, actor domain.Actor, id string, name string) error { + return a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) // access rule, returns domain errors + if err != nil { return err } + ... + ch.Environment(scope.Org, scope.Environment.ID) // what the dashboard must refetch + return nil + }) +} +``` + +- `a.read` / `a.write` wrap `Store.Read` / `Store.Write`. `write` publishes the collected + `Changes` after commit (a crash in between loses only an invalidation; clients refetch + everything on reconnect). +- `Tx` is the composite of per-area interfaces (`ProjectsTx`, `NodesTx`, …) declared in + `app/ports_.go` and implemented in `adapters/sqlite/.go`. +- Long work (Docker, Axiom, Caddy) happens outside the transaction: read, release, call out, + write the outcome. Never hold the write lock across a network call. +- Background work (Convex `scheduler.runAfter`, crons) goes through the `Jobs` port: + `After(key, delay, fn)` coalesces by key (a pending job with the same key wins), `Every(name, + interval, fn)`. On start, `serve` runs the recovery pass: observe every service, observe Swarm + nodes, proxy sync, data migrations. That replaces durable scheduling until workflows land. + +## Errors + +`domain.Error{Code, Message}`. `Code` is the CLI's code vocabulary (`api.Code*`), `Message` is the +human sentence (kept identical to the Convex `ConvexError` messages). Transport maps codes to +HTTP status and writes RFC 9457 `application/problem+json` with the extra field `code`: + +| Code | Status | +| ------------------------------------------------------------------------------------------ | ------ | +| `NOT_AUTHENTICATED` | 401 | +| `NO_ORGANIZATION`, `FORBIDDEN` | 403 | +| `NOT_FOUND`, `PROJECT_NOT_FOUND`, `SERVICE_NOT_FOUND`, `VARIABLE_NOT_FOUND`, `DEPLOYMENT_NOT_FOUND` | 404 | +| `NAME_TAKEN`, `NOTHING_TO_SHIP`, `DEPLOYMENT_RUNNING`, `CONFLICT`, `TRACES_OFF` | 409 | +| `AUTHORIZATION_PENDING` | 428 | +| `INVALID_INPUT` | 422 | +| `UNAVAILABLE` | 503 | +| `SERVER_ERROR` (anything else; message is generic, the cause is logged) | 500 | + +## HTTP API + +- Huma v2 on `net/http` (`humago`), everything JSON under `/api`. OpenAPI at `/api/openapi.json`. +- `operationId` is camelCase verb+noun (`listProjects`, `createService`, `shipEnvironment`): the + dashboard's hooks are generated from it (`useListProjects`). Tags = area. +- Auth: cookie `keel_session` (dashboard, HttpOnly, SameSite=Lax, Secure on https) or + `Authorization: Bearer ` (CLI). Middleware resolves `domain.Actor`; handlers + pass it to `app`. Same origin for dashboard, API and WebSocket: no CORS. +- Raw (non-Huma) routes, paths unchanged from Convex so deployed workers and services keep + working across the switch: `POST /worker/events`, `GET /worker/config`, `POST /proxy/events` + (bearer `KEEL_WORKER_TOKEN`), `POST /otlp/v1/traces` (environment ingest key). +- `GET /config.js` stays (runtime config for the dashboard); `GET /api/meta` is what the CLI + discovers an install with. + +## Realtime + +- One WebSocket per tab: `GET /api/ws`, centrifuge server, `centrifuge` JS client. +- Connect auth = the same session. The server subscribes the connection to `org:` + (server-side subscription); a client never picks channels. +- Publications: `{"type":"invalidate","topics":["/api/environments/", "/api/nodes/", …]}`. + A topic is a URL path prefix. The dashboard invalidates every TanStack query whose key (the + request path, see below) starts with a topic. Reconnect = invalidate everything. +- `Changes` helpers name the topics so handlers never spell paths: `ch.Projects(org)`, + `ch.Environment(org, envID)` (canvas, summary, deployments of it), `ch.Node(org, nodeID)`, + `ch.Organization(org)` (members, invitations, sink). + +## Dashboard + +- `apps/web` stays a Vite + React app. `kubb.config.ts` reads `../../openapi.json` and generates + `src/api/gen/` (types, zod schemas, fetch client, TanStack Query hooks). +- Query keys: the first element is the resolved request path (`/api/environments/abc/canvas`), + so invalidation topics match by prefix. +- `src/lib/realtime.tsx`: the WebSocket provider. Components only call generated hooks. +- Production: `apps/web/embed.go` embeds `dist/`; `keel serve` serves it with an SPA fallback. + Dev: Vite proxies `/api`, `/worker`, `/otlp`, `/config.js` to `keel serve`. + +## Env (serve) + +| Var | Meaning | +| ------------------------------------- | ------------------------------------------------------------------------------ | +| `KEEL_LISTEN` (`:8080`) | HTTP listen address (compose publishes it on the tailnet IP) | +| `KEEL_DATA_DIR` (`/data`) | SQLite file and other state | +| `KEEL_SITE_URL` | Dashboard URL as users open it (device-login links, Secure cookies on https) | +| `KEEL_WORKER_TOKEN` | Bearer for agent and proxy routes | +| `KEEL_PUBLIC_IP`, `KEEL_ACME_CA`, `KEEL_ACME_EMAIL` | Ingress, as before (docs/networking.md) | +| `KEEL_OTLP_URL` | OTLP relay URL injected into traced services | +| `KEEL_PROXY_ADMIN` (`/run/keel-proxy/admin.sock`) | keel-proxy admin socket | +| `DOCKER_HOST` | Docker socket (default `unix:///var/run/docker.sock`) | +| `KEEL_AXIOM_AUTH_URL`, `KEEL_AXIOM_API_URL`, `KEEL_ALLOW_LOCAL_SINKS` | Axiom overrides for tests, as before | + +## Testing + +- `domain`: plain table tests. `app`: against the real SQLite adapter on a temp file (fast, no + mocks) plus hand-written fakes for Swarm/Proxy/Axiom ports. Adapters: real SQLite; Swarm against + a Docker-in-Docker Swarm in CI, never the dev box's shared Swarm. +- `go vet ./...`, `gofmt`, `go test ./...` must pass. `bun run check-types` + `bun run build` in + `apps/web` must pass. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..91d1af8 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/ThallesP/keel + +go 1.27.1 + +require ( + github.com/danielgtaylor/huma/v2 v2.39.1 + github.com/spf13/cobra v1.10.2 + golang.org/x/text v0.42.0 + modernc.org/sqlite v1.60.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5da6da6 --- /dev/null +++ b/go.sum @@ -0,0 +1,79 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= +github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/adapters/sqlite/auth.go b/internal/adapters/sqlite/auth.go new file mode 100644 index 0000000..4dc4f6f --- /dev/null +++ b/internal/adapters/sqlite/auth.go @@ -0,0 +1,3 @@ +package sqlite + +// Implements app.AuthTx. Owner: the auth area. diff --git a/internal/adapters/sqlite/canvas.go b/internal/adapters/sqlite/canvas.go new file mode 100644 index 0000000..47500e8 --- /dev/null +++ b/internal/adapters/sqlite/canvas.go @@ -0,0 +1,3 @@ +package sqlite + +// Implements app.CanvasTx. Owner: the canvas area. diff --git a/internal/adapters/sqlite/core.go b/internal/adapters/sqlite/core.go new file mode 100644 index 0000000..50989b8 --- /dev/null +++ b/internal/adapters/sqlite/core.go @@ -0,0 +1,261 @@ +package sqlite + +import ( + "encoding/json" + "errors" + + "github.com/ThallesP/keel/internal/adapters/sqlite/db" + "github.com/ThallesP/keel/internal/app" + "github.com/ThallesP/keel/internal/domain" +) + +func projectOf(p db.Project) domain.Project { + return domain.Project{ID: p.ID, OrganizationID: p.OrganizationID, Name: p.Name, Slug: p.Slug, CreatedAt: p.CreatedAt} +} + +func environmentOf(e db.Environment) domain.Environment { + return domain.Environment{ID: e.ID, ProjectID: e.ProjectID, Name: e.Name, IsProduction: e.IsProduction != 0, CreatedAt: e.CreatedAt} +} + +func (t *tx) Project(id string) (domain.Project, error) { + p, err := t.q.CoreGetProject(t.ctx, id) + if err != nil { + return domain.Project{}, noRow(err) + } + return projectOf(p), nil +} + +func (t *tx) Environment(id string) (domain.Environment, error) { + e, err := t.q.CoreGetEnvironment(t.ctx, id) + if err != nil { + return domain.Environment{}, noRow(err) + } + return environmentOf(e), nil +} + +func (t *tx) OrganizationOfEnvironment(environmentID string) (string, error) { + org, err := t.q.CoreOrganizationOfEnvironment(t.ctx, environmentID) + return org, noRow(err) +} + +// nodeOf maps a row; endpoints are attached by the caller. +func nodeOf(r db.Node) domain.Node { + n := domain.Node{ + ID: r.ID, + EnvironmentID: r.EnvironmentID, + Type: domain.NodeType(r.Type), + Name: r.Name, + ParentID: str(r.ParentID), + Position: domain.Position{X: r.PositionX, Y: r.PositionY}, + Config: domain.NodeConfig{SizeGb: r.ConfigSizeGb, Width: r.ConfigWidth, Height: r.ConfigHeight}, + DeployedRevision: ptrInt(r.DeployedRevision), + Dirty: r.Dirty != 0, + ShippedAt: r.ShippedAt, + ApplyError: str(r.ApplyError), + OneShot: r.OneShot != 0, + CreatedAt: r.CreatedAt, + } + if r.DesiredImage != nil { + d := &domain.Desired{Image: *r.DesiredImage, Port: ptrInt(r.DesiredPort), Tracing: r.DesiredTracing != 0} + if r.DesiredRevision != nil { + d.Revision = int(*r.DesiredRevision) + } + if r.DesiredReplicas != nil { + d.Replicas = int(*r.DesiredReplicas) + } + n.Desired = d + } + if r.ObservedAt != nil { + o := &domain.Observed{ + Completed: ptrInt(r.ObservedCompleted), + FinishedAt: r.ObservedFinishedAt, + State: domain.ObservedState(str(r.ObservedState)), + Error: str(r.ObservedError), + At: *r.ObservedAt, + NodeIDs: []string{}, + } + if r.ObservedRevision != nil { + o.Revision = int(*r.ObservedRevision) + } + if r.ObservedRunning != nil { + o.Running = int(*r.ObservedRunning) + } + if r.ObservedNodeIds != nil { + _ = json.Unmarshal([]byte(*r.ObservedNodeIds), &o.NodeIDs) + } + n.Observed = o + } + return n +} + +func endpointOf(r db.Endpoint) domain.Endpoint { + return domain.Endpoint{ + ID: r.ID, + NodeID: r.NodeID, + Protocol: domain.EndpointProtocol(r.Protocol), + Port: int(r.Port), + PinnedPort: r.PinnedPort != 0, + Domain: str(r.Domain), + PublicPort: ptrInt(r.PublicPort), + Status: domain.EndpointStatus{State: domain.EndpointState(r.StatusState), Error: str(r.StatusError), At: r.StatusAt}, + } +} + +func (t *tx) Node(id string) (domain.Node, error) { + r, err := t.q.CoreGetNode(t.ctx, id) + if err != nil { + return domain.Node{}, noRow(err) + } + n := nodeOf(r) + eps, err := t.q.CoreListEndpointsByNode(t.ctx, id) + if err != nil { + return domain.Node{}, err + } + for _, e := range eps { + n.Endpoints = append(n.Endpoints, endpointOf(e)) + } + return n, nil +} + +func attach(rows []db.Node, eps []db.Endpoint) []domain.Node { + byNode := map[string][]domain.Endpoint{} + for _, e := range eps { + byNode[e.NodeID] = append(byNode[e.NodeID], endpointOf(e)) + } + out := make([]domain.Node, 0, len(rows)) + for _, r := range rows { + n := nodeOf(r) + n.Endpoints = byNode[r.ID] + out = append(out, n) + } + return out +} + +func (t *tx) Nodes(environmentID string) ([]domain.Node, error) { + rows, err := t.q.CoreListNodesByEnvironment(t.ctx, environmentID) + if err != nil { + return nil, err + } + eps, err := t.q.CoreListEndpointsByEnvironment(t.ctx, environmentID) + if err != nil { + return nil, err + } + return attach(rows, eps), nil +} + +func (t *tx) AllNodes() ([]domain.Node, error) { + rows, err := t.q.CoreListAllNodes(t.ctx) + if err != nil { + return nil, err + } + eps, err := t.q.CoreListAllEndpoints(t.ctx) + if err != nil { + return nil, err + } + return attach(rows, eps), nil +} + +func nodeParams(n domain.Node) db.CoreInsertNodeParams { + p := db.CoreInsertNodeParams{ + ID: n.ID, + EnvironmentID: n.EnvironmentID, + Type: string(n.Type), + Name: n.Name, + ParentID: nullStr(n.ParentID), + PositionX: n.Position.X, + PositionY: n.Position.Y, + ConfigSizeGb: n.Config.SizeGb, + ConfigWidth: n.Config.Width, + ConfigHeight: n.Config.Height, + DeployedRevision: ptrInt64(n.DeployedRevision), + Dirty: b2i(n.Dirty), + ShippedAt: n.ShippedAt, + ApplyError: nullStr(n.ApplyError), + OneShot: b2i(n.OneShot), + CreatedAt: n.CreatedAt, + } + if d := n.Desired; d != nil { + img, rev, rep := d.Image, int64(d.Revision), int64(d.Replicas) + p.DesiredImage, p.DesiredRevision, p.DesiredReplicas = &img, &rev, &rep + p.DesiredPort = ptrInt64(d.Port) + p.DesiredTracing = b2i(d.Tracing) + } + if o := n.Observed; o != nil { + rev, run, at, state := int64(o.Revision), int64(o.Running), o.At, string(o.State) + ids := o.NodeIDs + if ids == nil { + ids = []string{} + } + b, _ := json.Marshal(ids) + js := string(b) + p.ObservedRevision, p.ObservedRunning, p.ObservedAt, p.ObservedState = &rev, &run, &at, &state + p.ObservedCompleted = ptrInt64(o.Completed) + p.ObservedFinishedAt = o.FinishedAt + p.ObservedNodeIds = &js + p.ObservedError = nullStr(o.Error) + } + return p +} + +func (t *tx) InsertNode(n domain.Node) error { + return t.q.CoreInsertNode(t.ctx, nodeParams(n)) +} + +func (t *tx) UpdateNode(n domain.Node) error { + res, err := t.q.CoreUpdateNode(t.ctx, db.CoreUpdateNodeParams(nodeParams(n))) + if err != nil { + return err + } + if res == 0 { + return app.ErrNoRow + } + return nil +} + +func (t *tx) DeleteNode(id string) error { + return t.q.CoreDeleteNode(t.ctx, id) +} + +func (t *tx) ReplaceEndpoints(nodeID string, eps []domain.Endpoint) error { + if err := t.q.CoreDeleteEndpointsOfNode(t.ctx, nodeID); err != nil { + return err + } + for i, e := range eps { + id := e.ID + if id == "" { + id = domain.NewID() + } + err := t.q.CoreInsertEndpoint(t.ctx, db.CoreInsertEndpointParams{ + ID: id, + NodeID: nodeID, + Ord: int64(i), + Protocol: string(e.Protocol), + Port: int64(e.Port), + PinnedPort: b2i(e.PinnedPort), + Domain: nullStr(e.Domain), + PublicPort: ptrInt64(e.PublicPort), + StatusState: string(e.Status.State), + StatusError: nullStr(e.Status.Error), + StatusAt: e.Status.At, + }) + if err != nil { + return err + } + } + return nil +} + +func (t *tx) Setting(key string) (string, bool, error) { + v, err := t.q.CoreGetSetting(t.ctx, key) + if errors.Is(noRow(err), app.ErrNoRow) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return v, true, nil +} + +func (t *tx) SetSetting(key, value string) error { + return t.q.CoreSetSetting(t.ctx, db.CoreSetSettingParams{Key: key, Value: value}) +} diff --git a/internal/adapters/sqlite/core_test.go b/internal/adapters/sqlite/core_test.go new file mode 100644 index 0000000..196a252 --- /dev/null +++ b/internal/adapters/sqlite/core_test.go @@ -0,0 +1,101 @@ +package sqlite + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "github.com/ThallesP/keel/internal/app" + "github.com/ThallesP/keel/internal/domain" +) + +// OpenTest opens a fresh database in t's temp dir. +func OpenTest(t testing.TB) *Store { + t.Helper() + s, err := Open(context.Background(), filepath.Join(t.TempDir(), "keel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func seedEnvironment(t *testing.T, s *Store) string { + t.Helper() + for _, q := range []string{ + `INSERT INTO organizations (id, name, slug, created_at) VALUES ('org', 'Default', 'default', 1)`, + `INSERT INTO projects (id, organization_id, name, slug, created_at) VALUES ('p', 'org', 'Acme', 'acme', 1)`, + `INSERT INTO environments (id, project_id, name, is_production, created_at) VALUES ('env', 'p', 'production', 1, 1)`, + } { + if _, err := s.DB().Exec(q); err != nil { + t.Fatal(err) + } + } + return "env" +} + +func TestNodeRoundTrip(t *testing.T) { + s := OpenTest(t) + env := seedEnvironment(t, s) + port, completed, finished := 8080, 1, int64(99) + want := domain.Node{ + ID: domain.NewID(), EnvironmentID: env, Type: domain.NodeService, Name: "api", + Position: domain.Position{X: 1.5, Y: -2}, + Desired: &domain.Desired{Image: "nginx:1", Revision: 3, Replicas: 2, Port: &port, Tracing: true}, + Observed: &domain.Observed{Revision: 3, Running: 2, Completed: &completed, FinishedAt: &finished, + State: domain.ObservedOK, NodeIDs: []string{"n1"}, At: 42}, + Dirty: true, ApplyError: "boom", OneShot: true, CreatedAt: 7, + } + pub := 5432 + eps := []domain.Endpoint{ + {Protocol: domain.ProtocolHTTP, Port: 8080, Domain: "api.example.com", Status: domain.EndpointStatus{State: domain.EndpointLive, At: 1}}, + {Protocol: domain.ProtocolTCP, Port: 5432, PublicPort: &pub, PinnedPort: true, Status: domain.EndpointStatus{State: domain.EndpointFailed, Error: "in use", At: 2}}, + } + ctx := context.Background() + err := s.Write(ctx, func(tx app.Tx) error { + if err := tx.InsertNode(want); err != nil { + return err + } + return tx.ReplaceEndpoints(want.ID, eps) + }) + if err != nil { + t.Fatal(err) + } + var got domain.Node + if err := s.Read(ctx, func(tx app.Tx) (err error) { got, err = tx.Node(want.ID); return }); err != nil { + t.Fatal(err) + } + for i := range got.Endpoints { + if got.Endpoints[i].ID == "" || got.Endpoints[i].NodeID != want.ID { + t.Fatalf("endpoint %d: %+v", i, got.Endpoints[i]) + } + eps[i].ID, eps[i].NodeID = got.Endpoints[i].ID, want.ID + } + want.Endpoints = eps + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip:\n got %+v\nwant %+v", got, want) + } + + // Update clears desired/observed; a missing node is ErrNoRow. + got.Desired, got.Observed, got.Name = nil, nil, "api-2" + if err := s.Write(ctx, func(tx app.Tx) error { return tx.UpdateNode(got) }); err != nil { + t.Fatal(err) + } + err = s.Read(ctx, func(tx app.Tx) error { + n, err := tx.Node(want.ID) + if err != nil { + return err + } + if n.Desired != nil || n.Observed != nil || n.Name != "api-2" { + t.Errorf("after update: %+v", n) + } + if _, err := tx.Node("missing"); err != app.ErrNoRow { + t.Errorf("missing node: %v", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/adapters/sqlite/db/core.sql.go b/internal/adapters/sqlite/db/core.sql.go new file mode 100644 index 0000000..3d9a0ef --- /dev/null +++ b/internal/adapters/sqlite/db/core.sql.go @@ -0,0 +1,585 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: core.sql + +package db + +import ( + "context" +) + +const coreDeleteEndpointsOfNode = `-- name: CoreDeleteEndpointsOfNode :exec +DELETE FROM endpoints WHERE node_id = ? +` + +func (q *Queries) CoreDeleteEndpointsOfNode(ctx context.Context, nodeID string) error { + _, err := q.db.ExecContext(ctx, coreDeleteEndpointsOfNode, nodeID) + return err +} + +const coreDeleteNode = `-- name: CoreDeleteNode :exec +DELETE FROM nodes WHERE id = ? +` + +func (q *Queries) CoreDeleteNode(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, coreDeleteNode, id) + return err +} + +const coreGetEnvironment = `-- name: CoreGetEnvironment :one +SELECT id, project_id, name, is_production, created_at FROM environments WHERE id = ? +` + +func (q *Queries) CoreGetEnvironment(ctx context.Context, id string) (Environment, error) { + row := q.db.QueryRowContext(ctx, coreGetEnvironment, id) + var i Environment + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.IsProduction, + &i.CreatedAt, + ) + return i, err +} + +const coreGetNode = `-- name: CoreGetNode :one +SELECT id, environment_id, type, name, parent_id, position_x, position_y, config_size_gb, config_width, config_height, desired_image, desired_revision, desired_replicas, desired_port, desired_tracing, observed_revision, observed_running, observed_completed, observed_finished_at, observed_state, observed_node_ids, observed_error, observed_at, deployed_revision, dirty, shipped_at, apply_error, one_shot, created_at FROM nodes WHERE id = ? +` + +func (q *Queries) CoreGetNode(ctx context.Context, id string) (Node, error) { + row := q.db.QueryRowContext(ctx, coreGetNode, id) + var i Node + err := row.Scan( + &i.ID, + &i.EnvironmentID, + &i.Type, + &i.Name, + &i.ParentID, + &i.PositionX, + &i.PositionY, + &i.ConfigSizeGb, + &i.ConfigWidth, + &i.ConfigHeight, + &i.DesiredImage, + &i.DesiredRevision, + &i.DesiredReplicas, + &i.DesiredPort, + &i.DesiredTracing, + &i.ObservedRevision, + &i.ObservedRunning, + &i.ObservedCompleted, + &i.ObservedFinishedAt, + &i.ObservedState, + &i.ObservedNodeIds, + &i.ObservedError, + &i.ObservedAt, + &i.DeployedRevision, + &i.Dirty, + &i.ShippedAt, + &i.ApplyError, + &i.OneShot, + &i.CreatedAt, + ) + return i, err +} + +const coreGetProject = `-- name: CoreGetProject :one + +SELECT id, organization_id, name, slug, created_at FROM projects WHERE id = ? +` + +// Shared reads every area uses. Area files are named queries/.sql and prefix their query +// ASCII only in this directory: sqlc miscounts offsets after multi-byte characters. +// names with the area (AuthGetUser, CanvasInsertNode, DeployListRunning, ...) so that sqlc's one +// generated package never sees a duplicate. +func (q *Queries) CoreGetProject(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRowContext(ctx, coreGetProject, id) + var i Project + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Name, + &i.Slug, + &i.CreatedAt, + ) + return i, err +} + +const coreGetSetting = `-- name: CoreGetSetting :one +SELECT value FROM settings WHERE key = ? +` + +func (q *Queries) CoreGetSetting(ctx context.Context, key string) (string, error) { + row := q.db.QueryRowContext(ctx, coreGetSetting, key) + var value string + err := row.Scan(&value) + return value, err +} + +const coreInsertEndpoint = `-- name: CoreInsertEndpoint :exec +INSERT INTO endpoints ( + id, node_id, ord, protocol, port, pinned_port, domain, public_port, + status_state, status_error, status_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CoreInsertEndpointParams struct { + ID string + NodeID string + Ord int64 + Protocol string + Port int64 + PinnedPort int64 + Domain *string + PublicPort *int64 + StatusState string + StatusError *string + StatusAt int64 +} + +func (q *Queries) CoreInsertEndpoint(ctx context.Context, arg CoreInsertEndpointParams) error { + _, err := q.db.ExecContext(ctx, coreInsertEndpoint, + arg.ID, + arg.NodeID, + arg.Ord, + arg.Protocol, + arg.Port, + arg.PinnedPort, + arg.Domain, + arg.PublicPort, + arg.StatusState, + arg.StatusError, + arg.StatusAt, + ) + return err +} + +const coreInsertNode = `-- name: CoreInsertNode :exec +INSERT INTO nodes ( + id, environment_id, type, name, parent_id, position_x, position_y, + config_size_gb, config_width, config_height, + desired_image, desired_revision, desired_replicas, desired_port, desired_tracing, + observed_revision, observed_running, observed_completed, observed_finished_at, observed_state, + observed_node_ids, observed_error, observed_at, + deployed_revision, dirty, shipped_at, apply_error, one_shot, created_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ? +) +` + +type CoreInsertNodeParams struct { + ID string + EnvironmentID string + Type string + Name string + ParentID *string + PositionX float64 + PositionY float64 + ConfigSizeGb *float64 + ConfigWidth *float64 + ConfigHeight *float64 + DesiredImage *string + DesiredRevision *int64 + DesiredReplicas *int64 + DesiredPort *int64 + DesiredTracing int64 + ObservedRevision *int64 + ObservedRunning *int64 + ObservedCompleted *int64 + ObservedFinishedAt *int64 + ObservedState *string + ObservedNodeIds *string + ObservedError *string + ObservedAt *int64 + DeployedRevision *int64 + Dirty int64 + ShippedAt *int64 + ApplyError *string + OneShot int64 + CreatedAt int64 +} + +func (q *Queries) CoreInsertNode(ctx context.Context, arg CoreInsertNodeParams) error { + _, err := q.db.ExecContext(ctx, coreInsertNode, + arg.ID, + arg.EnvironmentID, + arg.Type, + arg.Name, + arg.ParentID, + arg.PositionX, + arg.PositionY, + arg.ConfigSizeGb, + arg.ConfigWidth, + arg.ConfigHeight, + arg.DesiredImage, + arg.DesiredRevision, + arg.DesiredReplicas, + arg.DesiredPort, + arg.DesiredTracing, + arg.ObservedRevision, + arg.ObservedRunning, + arg.ObservedCompleted, + arg.ObservedFinishedAt, + arg.ObservedState, + arg.ObservedNodeIds, + arg.ObservedError, + arg.ObservedAt, + arg.DeployedRevision, + arg.Dirty, + arg.ShippedAt, + arg.ApplyError, + arg.OneShot, + arg.CreatedAt, + ) + return err +} + +const coreListAllEndpoints = `-- name: CoreListAllEndpoints :many +SELECT id, node_id, ord, protocol, port, pinned_port, domain, public_port, status_state, status_error, status_at FROM endpoints ORDER BY node_id, ord +` + +func (q *Queries) CoreListAllEndpoints(ctx context.Context) ([]Endpoint, error) { + rows, err := q.db.QueryContext(ctx, coreListAllEndpoints) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Endpoint{} + for rows.Next() { + var i Endpoint + if err := rows.Scan( + &i.ID, + &i.NodeID, + &i.Ord, + &i.Protocol, + &i.Port, + &i.PinnedPort, + &i.Domain, + &i.PublicPort, + &i.StatusState, + &i.StatusError, + &i.StatusAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const coreListAllNodes = `-- name: CoreListAllNodes :many +SELECT id, environment_id, type, name, parent_id, position_x, position_y, config_size_gb, config_width, config_height, desired_image, desired_revision, desired_replicas, desired_port, desired_tracing, observed_revision, observed_running, observed_completed, observed_finished_at, observed_state, observed_node_ids, observed_error, observed_at, deployed_revision, dirty, shipped_at, apply_error, one_shot, created_at FROM nodes ORDER BY created_at, id +` + +func (q *Queries) CoreListAllNodes(ctx context.Context) ([]Node, error) { + rows, err := q.db.QueryContext(ctx, coreListAllNodes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Node{} + for rows.Next() { + var i Node + if err := rows.Scan( + &i.ID, + &i.EnvironmentID, + &i.Type, + &i.Name, + &i.ParentID, + &i.PositionX, + &i.PositionY, + &i.ConfigSizeGb, + &i.ConfigWidth, + &i.ConfigHeight, + &i.DesiredImage, + &i.DesiredRevision, + &i.DesiredReplicas, + &i.DesiredPort, + &i.DesiredTracing, + &i.ObservedRevision, + &i.ObservedRunning, + &i.ObservedCompleted, + &i.ObservedFinishedAt, + &i.ObservedState, + &i.ObservedNodeIds, + &i.ObservedError, + &i.ObservedAt, + &i.DeployedRevision, + &i.Dirty, + &i.ShippedAt, + &i.ApplyError, + &i.OneShot, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const coreListEndpointsByEnvironment = `-- name: CoreListEndpointsByEnvironment :many +SELECT e.id, e.node_id, e.ord, e.protocol, e.port, e.pinned_port, e.domain, e.public_port, e.status_state, e.status_error, e.status_at FROM endpoints e JOIN nodes n ON n.id = e.node_id +WHERE n.environment_id = ? ORDER BY e.node_id, e.ord +` + +func (q *Queries) CoreListEndpointsByEnvironment(ctx context.Context, environmentID string) ([]Endpoint, error) { + rows, err := q.db.QueryContext(ctx, coreListEndpointsByEnvironment, environmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Endpoint{} + for rows.Next() { + var i Endpoint + if err := rows.Scan( + &i.ID, + &i.NodeID, + &i.Ord, + &i.Protocol, + &i.Port, + &i.PinnedPort, + &i.Domain, + &i.PublicPort, + &i.StatusState, + &i.StatusError, + &i.StatusAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const coreListEndpointsByNode = `-- name: CoreListEndpointsByNode :many +SELECT id, node_id, ord, protocol, port, pinned_port, domain, public_port, status_state, status_error, status_at FROM endpoints WHERE node_id = ? ORDER BY ord +` + +func (q *Queries) CoreListEndpointsByNode(ctx context.Context, nodeID string) ([]Endpoint, error) { + rows, err := q.db.QueryContext(ctx, coreListEndpointsByNode, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Endpoint{} + for rows.Next() { + var i Endpoint + if err := rows.Scan( + &i.ID, + &i.NodeID, + &i.Ord, + &i.Protocol, + &i.Port, + &i.PinnedPort, + &i.Domain, + &i.PublicPort, + &i.StatusState, + &i.StatusError, + &i.StatusAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const coreListNodesByEnvironment = `-- name: CoreListNodesByEnvironment :many +SELECT id, environment_id, type, name, parent_id, position_x, position_y, config_size_gb, config_width, config_height, desired_image, desired_revision, desired_replicas, desired_port, desired_tracing, observed_revision, observed_running, observed_completed, observed_finished_at, observed_state, observed_node_ids, observed_error, observed_at, deployed_revision, dirty, shipped_at, apply_error, one_shot, created_at FROM nodes WHERE environment_id = ? ORDER BY created_at, id +` + +func (q *Queries) CoreListNodesByEnvironment(ctx context.Context, environmentID string) ([]Node, error) { + rows, err := q.db.QueryContext(ctx, coreListNodesByEnvironment, environmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Node{} + for rows.Next() { + var i Node + if err := rows.Scan( + &i.ID, + &i.EnvironmentID, + &i.Type, + &i.Name, + &i.ParentID, + &i.PositionX, + &i.PositionY, + &i.ConfigSizeGb, + &i.ConfigWidth, + &i.ConfigHeight, + &i.DesiredImage, + &i.DesiredRevision, + &i.DesiredReplicas, + &i.DesiredPort, + &i.DesiredTracing, + &i.ObservedRevision, + &i.ObservedRunning, + &i.ObservedCompleted, + &i.ObservedFinishedAt, + &i.ObservedState, + &i.ObservedNodeIds, + &i.ObservedError, + &i.ObservedAt, + &i.DeployedRevision, + &i.Dirty, + &i.ShippedAt, + &i.ApplyError, + &i.OneShot, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const coreOrganizationOfEnvironment = `-- name: CoreOrganizationOfEnvironment :one +SELECT p.organization_id FROM environments e JOIN projects p ON p.id = e.project_id +WHERE e.id = ? +` + +func (q *Queries) CoreOrganizationOfEnvironment(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, coreOrganizationOfEnvironment, id) + var organization_id string + err := row.Scan(&organization_id) + return organization_id, err +} + +const coreSetSetting = `-- name: CoreSetSetting :exec +INSERT INTO settings (key, value) VALUES (?, ?) +ON CONFLICT (key) DO UPDATE SET value = excluded.value +` + +type CoreSetSettingParams struct { + Key string + Value string +} + +func (q *Queries) CoreSetSetting(ctx context.Context, arg CoreSetSettingParams) error { + _, err := q.db.ExecContext(ctx, coreSetSetting, arg.Key, arg.Value) + return err +} + +const coreUpdateNode = `-- name: CoreUpdateNode :execrows +UPDATE nodes SET + environment_id = ?2, type = ?3, name = ?4, parent_id = ?5, position_x = ?6, position_y = ?7, + config_size_gb = ?8, config_width = ?9, config_height = ?10, + desired_image = ?11, desired_revision = ?12, desired_replicas = ?13, desired_port = ?14, + desired_tracing = ?15, + observed_revision = ?16, observed_running = ?17, observed_completed = ?18, + observed_finished_at = ?19, observed_state = ?20, observed_node_ids = ?21, observed_error = ?22, + observed_at = ?23, + deployed_revision = ?24, dirty = ?25, shipped_at = ?26, apply_error = ?27, one_shot = ?28, + created_at = ?29 +WHERE id = ?1 +` + +type CoreUpdateNodeParams struct { + ID string + EnvironmentID string + Type string + Name string + ParentID *string + PositionX float64 + PositionY float64 + ConfigSizeGb *float64 + ConfigWidth *float64 + ConfigHeight *float64 + DesiredImage *string + DesiredRevision *int64 + DesiredReplicas *int64 + DesiredPort *int64 + DesiredTracing int64 + ObservedRevision *int64 + ObservedRunning *int64 + ObservedCompleted *int64 + ObservedFinishedAt *int64 + ObservedState *string + ObservedNodeIds *string + ObservedError *string + ObservedAt *int64 + DeployedRevision *int64 + Dirty int64 + ShippedAt *int64 + ApplyError *string + OneShot int64 + CreatedAt int64 +} + +func (q *Queries) CoreUpdateNode(ctx context.Context, arg CoreUpdateNodeParams) (int64, error) { + result, err := q.db.ExecContext(ctx, coreUpdateNode, + arg.ID, + arg.EnvironmentID, + arg.Type, + arg.Name, + arg.ParentID, + arg.PositionX, + arg.PositionY, + arg.ConfigSizeGb, + arg.ConfigWidth, + arg.ConfigHeight, + arg.DesiredImage, + arg.DesiredRevision, + arg.DesiredReplicas, + arg.DesiredPort, + arg.DesiredTracing, + arg.ObservedRevision, + arg.ObservedRunning, + arg.ObservedCompleted, + arg.ObservedFinishedAt, + arg.ObservedState, + arg.ObservedNodeIds, + arg.ObservedError, + arg.ObservedAt, + arg.DeployedRevision, + arg.Dirty, + arg.ShippedAt, + arg.ApplyError, + arg.OneShot, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/adapters/sqlite/db/db.go b/internal/adapters/sqlite/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/adapters/sqlite/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/adapters/sqlite/db/models.go b/internal/adapters/sqlite/db/models.go new file mode 100644 index 0000000..c709e07 --- /dev/null +++ b/internal/adapters/sqlite/db/models.go @@ -0,0 +1,214 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +type AxiomClient struct { + RedirectUri string + ClientID string + CreatedAt int64 +} + +type AxiomPending struct { + OrganizationID string + Token string + Orgs string + CreatedAt int64 +} + +type AxiomSignIn struct { + State string + OrganizationID string + ClientID string + Verifier string + RedirectUri string + CreatedAt int64 +} + +type Cluster struct { + ID int64 + Servers int64 + At int64 +} + +type Deployment struct { + ID string + EnvironmentID string + Sha *string + Message string + Status string + StartedAt int64 + FinishedAt *int64 +} + +type DeploymentLog struct { + Seq int64 + DeploymentID string + At int64 + NodeID *string + Text string +} + +type DeploymentStep struct { + DeploymentID string + Idx int64 + NodeID *string + Label string + Status string + StartedAt *int64 + AppliedAt *int64 + FinishedAt *int64 +} + +type DeviceCode struct { + ID string + DeviceCodeHash string + UserCode string + ClientID string + Status string + UserID *string + IntervalS int64 + LastPolledAt *int64 + ExpiresAt int64 + CreatedAt int64 +} + +type Endpoint struct { + ID string + NodeID string + Ord int64 + Protocol string + Port int64 + PinnedPort int64 + Domain *string + PublicPort *int64 + StatusState string + StatusError *string + StatusAt int64 +} + +type Environment struct { + ID string + ProjectID string + Name string + IsProduction int64 + CreatedAt int64 +} + +type Invitation struct { + ID string + OrganizationID string + Email string + Role string + Status string + InviterID string + ExpiresAt int64 + CreatedAt int64 +} + +type LogSink struct { + ID string + OrganizationID string + Kind string + Domain string + Dataset string + Traces *string + Token string + Org *string + CreatedAt int64 +} + +type Member struct { + ID string + OrganizationID string + UserID string + Role string + CreatedAt int64 +} + +type Node struct { + ID string + EnvironmentID string + Type string + Name string + ParentID *string + PositionX float64 + PositionY float64 + ConfigSizeGb *float64 + ConfigWidth *float64 + ConfigHeight *float64 + DesiredImage *string + DesiredRevision *int64 + DesiredReplicas *int64 + DesiredPort *int64 + DesiredTracing int64 + ObservedRevision *int64 + ObservedRunning *int64 + ObservedCompleted *int64 + ObservedFinishedAt *int64 + ObservedState *string + ObservedNodeIds *string + ObservedError *string + ObservedAt *int64 + DeployedRevision *int64 + Dirty int64 + ShippedAt *int64 + ApplyError *string + OneShot int64 + CreatedAt int64 +} + +type Organization struct { + ID string + Name string + Slug string + CreatedAt int64 +} + +type OtlpKey struct { + EnvironmentID string + Key string + CreatedAt int64 +} + +type Project struct { + ID string + OrganizationID string + Name string + Slug string + CreatedAt int64 +} + +type Session struct { + ID string + TokenHash string + UserID string + ExpiresAt int64 + CreatedAt int64 + UpdatedAt int64 + UserAgent string + Ip string +} + +type Setting struct { + Key string + Value string +} + +type User struct { + ID string + Email string + Name string + PasswordHash string + CreatedAt int64 + UpdatedAt int64 +} + +type Variable struct { + ID string + NodeID string + Key string + Value string + Secret int64 +} diff --git a/internal/adapters/sqlite/deploy.go b/internal/adapters/sqlite/deploy.go new file mode 100644 index 0000000..2d24ffb --- /dev/null +++ b/internal/adapters/sqlite/deploy.go @@ -0,0 +1,3 @@ +package sqlite + +// Implements app.DeployTx. Owner: the deploy area. diff --git a/internal/adapters/sqlite/ingress.go b/internal/adapters/sqlite/ingress.go new file mode 100644 index 0000000..3913cd5 --- /dev/null +++ b/internal/adapters/sqlite/ingress.go @@ -0,0 +1,3 @@ +package sqlite + +// Implements app.IngressTx. Owner: the ingress area. diff --git a/internal/adapters/sqlite/migrations/0001_init.sql b/internal/adapters/sqlite/migrations/0001_init.sql new file mode 100644 index 0000000..7280ece --- /dev/null +++ b/internal/adapters/sqlite/migrations/0001_init.sql @@ -0,0 +1,254 @@ +-- Keel's control-plane database. Ported from convex/schema.ts plus Better Auth's tables. +-- Times are unix milliseconds. Booleans are INTEGER 0/1. Ids are domain.NewID() strings. + +-- ── Accounts ──────────────────────────────────────────────────────────────────────────────── + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + name TEXT NOT NULL DEFAULT '', + -- argon2id PHC string ($argon2id$v=19$m=…,t=…,p=…$salt$hash). An account imported from Better + -- Auth may hold its scrypt "salt:hash" until its next sign-in rehashes it. + password_hash TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- A signed-in browser or CLI. Only the SHA-256 of the token is stored. +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '' +); +CREATE INDEX sessions_by_user ON sessions(user_id); + +-- One per install for now, founded by the first account (projects.ensureDefault). +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +CREATE TABLE members ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + created_at INTEGER NOT NULL, + UNIQUE (organization_id, user_id) +); +CREATE INDEX members_by_user ON members(user_id); + +-- The id is the secret in the invite link. +CREATE TABLE invitations ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email TEXT NOT NULL COLLATE NOCASE, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'canceled', 'rejected')), + inviter_id TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX invitations_by_organization ON invitations(organization_id, status); + +-- `keel login` (RFC 8628). device_code_hash: SHA-256 of the secret the CLI polls with. +CREATE TABLE device_codes ( + id TEXT PRIMARY KEY, + device_code_hash TEXT NOT NULL UNIQUE, + user_code TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied')), + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + interval_s INTEGER NOT NULL DEFAULT 5, + last_polled_at INTEGER, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +-- ── Projects and the canvas ───────────────────────────────────────────────────────────────── + +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (organization_id, slug) +); + +CREATE TABLE environments ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + is_production INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE INDEX environments_by_project ON environments(project_id); + +-- A canvas node. `desired` is present iff desired_image IS NOT NULL; `observed` iff observed_at +-- IS NOT NULL (domain.Node). volume and group never have either. +CREATE TABLE nodes ( + id TEXT PRIMARY KEY, + environment_id TEXT NOT NULL REFERENCES environments(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('service', 'database', 'cache', 'volume', 'group')), + name TEXT NOT NULL, + parent_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, + position_x REAL NOT NULL DEFAULT 0, + position_y REAL NOT NULL DEFAULT 0, + config_size_gb REAL, + config_width REAL, + config_height REAL, + desired_image TEXT, + desired_revision INTEGER, + desired_replicas INTEGER, + desired_port INTEGER, + desired_tracing INTEGER NOT NULL DEFAULT 0, + observed_revision INTEGER, + observed_running INTEGER, + observed_completed INTEGER, + observed_finished_at INTEGER, + observed_state TEXT, + observed_node_ids TEXT, -- JSON array of Swarm node ids + observed_error TEXT, + observed_at INTEGER, + deployed_revision INTEGER, + dirty INTEGER NOT NULL DEFAULT 0, + shipped_at INTEGER, + apply_error TEXT, + one_shot INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +CREATE INDEX nodes_by_environment ON nodes(environment_id); + +-- nodes.endpoints in Convex. Uniqueness that Convex enforced in code is enforced here too. +CREATE TABLE endpoints ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + ord INTEGER NOT NULL, -- order within the node, as the Convex array kept it + protocol TEXT NOT NULL CHECK (protocol IN ('http', 'tcp', 'udp')), + port INTEGER NOT NULL, + pinned_port INTEGER NOT NULL DEFAULT 0, + domain TEXT, + public_port INTEGER, + status_state TEXT NOT NULL CHECK (status_state IN ('starting', 'live', 'failed')), + status_error TEXT, + status_at INTEGER NOT NULL +); +CREATE INDEX endpoints_by_node ON endpoints(node_id, ord); +CREATE UNIQUE INDEX endpoints_domain ON endpoints(domain) WHERE domain IS NOT NULL; +CREATE UNIQUE INDEX endpoints_public_port ON endpoints(protocol, public_port) WHERE public_port IS NOT NULL; + +CREATE TABLE variables ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + secret INTEGER NOT NULL DEFAULT 0, + UNIQUE (node_id, key) +); + +-- ── Deployments ───────────────────────────────────────────────────────────────────────────── + +CREATE TABLE deployments ( + id TEXT PRIMARY KEY, + environment_id TEXT NOT NULL REFERENCES environments(id) ON DELETE CASCADE, + sha TEXT, + message TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failed')), + started_at INTEGER NOT NULL, + finished_at INTEGER +); +CREATE INDEX deployments_by_environment ON deployments(environment_id, started_at); +CREATE INDEX deployments_by_status ON deployments(status); + +-- deployments.steps. node_id has no foreign key: a step outlives a deleted node. +CREATE TABLE deployment_steps ( + deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + node_id TEXT, + label TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')), + started_at INTEGER, + applied_at INTEGER, + finished_at INTEGER, + PRIMARY KEY (deployment_id, idx) +); + +-- deployments.log +CREATE TABLE deployment_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + at INTEGER NOT NULL, + node_id TEXT, + text TEXT NOT NULL +); +CREATE INDEX deployment_log_by_deployment ON deployment_log(deployment_id, seq); + +-- Single row: ready Swarm nodes. +CREATE TABLE cluster ( + id INTEGER PRIMARY KEY CHECK (id = 1), + servers INTEGER NOT NULL, + at INTEGER NOT NULL +); + +-- ── Observability ─────────────────────────────────────────────────────────────────────────── + +-- At most one per organization (docs/logs.md). token is a secret: it leaves the server only to +-- the agent (bearer-protected /worker/config). +CREATE TABLE log_sinks ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('axiom')), + domain TEXT NOT NULL, + dataset TEXT NOT NULL, + traces TEXT, + token TEXT NOT NULL, + org TEXT, + created_at INTEGER NOT NULL +); + +-- Sign in with Axiom: the OAuth client this install registered (DCR), one per callback URL. +CREATE TABLE axiom_clients ( + redirect_uri TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- Sign in with Axiom, between begin and Axiom's redirect back. Single use, 10 minutes. +CREATE TABLE axiom_sign_ins ( + state TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + client_id TEXT NOT NULL, + verifier TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX axiom_sign_ins_by_organization ON axiom_sign_ins(organization_id); + +-- Sign in with Axiom, between the code exchange and the user picking one of several orgs. +CREATE TABLE axiom_pending ( + organization_id TEXT PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + token TEXT NOT NULL, + orgs TEXT NOT NULL, -- JSON array of {id, name, domain, maxDatasets?} + created_at INTEGER NOT NULL +); + +-- The OTLP relay's ingest key of an environment. At most one per environment. +CREATE TABLE otlp_keys ( + environment_id TEXT PRIMARY KEY REFERENCES environments(id) ON DELETE CASCADE, + key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +-- Small install-wide settings (e.g. the public IP default domains were last computed with). +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/internal/adapters/sqlite/observability.go b/internal/adapters/sqlite/observability.go new file mode 100644 index 0000000..0e0bc76 --- /dev/null +++ b/internal/adapters/sqlite/observability.go @@ -0,0 +1,3 @@ +package sqlite + +// Implements app.ObservabilityTx. Owner: the observability area. diff --git a/internal/adapters/sqlite/queries/core.sql b/internal/adapters/sqlite/queries/core.sql new file mode 100644 index 0000000..974676d --- /dev/null +++ b/internal/adapters/sqlite/queries/core.sql @@ -0,0 +1,82 @@ +-- Shared reads every area uses. Area files are named queries/.sql and prefix their query +-- ASCII only in this directory: sqlc miscounts offsets after multi-byte characters. +-- names with the area (AuthGetUser, CanvasInsertNode, DeployListRunning, ...) so that sqlc's one +-- generated package never sees a duplicate. + +-- name: CoreGetProject :one +SELECT * FROM projects WHERE id = ?; + +-- name: CoreGetEnvironment :one +SELECT * FROM environments WHERE id = ?; + +-- name: CoreGetNode :one +SELECT * FROM nodes WHERE id = ?; + +-- name: CoreListNodesByEnvironment :many +SELECT * FROM nodes WHERE environment_id = ? ORDER BY created_at, id; + +-- name: CoreListAllNodes :many +SELECT * FROM nodes ORDER BY created_at, id; + +-- name: CoreListEndpointsByNode :many +SELECT * FROM endpoints WHERE node_id = ? ORDER BY ord; + +-- name: CoreListEndpointsByEnvironment :many +SELECT e.* FROM endpoints e JOIN nodes n ON n.id = e.node_id +WHERE n.environment_id = ? ORDER BY e.node_id, e.ord; + +-- name: CoreListAllEndpoints :many +SELECT * FROM endpoints ORDER BY node_id, ord; + +-- name: CoreOrganizationOfEnvironment :one +SELECT p.organization_id FROM environments e JOIN projects p ON p.id = e.project_id +WHERE e.id = ?; + +-- name: CoreGetSetting :one +SELECT value FROM settings WHERE key = ?; + +-- name: CoreSetSetting :exec +INSERT INTO settings (key, value) VALUES (?, ?) +ON CONFLICT (key) DO UPDATE SET value = excluded.value; + +-- name: CoreInsertNode :exec +INSERT INTO nodes ( + id, environment_id, type, name, parent_id, position_x, position_y, + config_size_gb, config_width, config_height, + desired_image, desired_revision, desired_replicas, desired_port, desired_tracing, + observed_revision, observed_running, observed_completed, observed_finished_at, observed_state, + observed_node_ids, observed_error, observed_at, + deployed_revision, dirty, shipped_at, apply_error, one_shot, created_at +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ? +); + +-- name: CoreUpdateNode :execrows +UPDATE nodes SET + environment_id = ?2, type = ?3, name = ?4, parent_id = ?5, position_x = ?6, position_y = ?7, + config_size_gb = ?8, config_width = ?9, config_height = ?10, + desired_image = ?11, desired_revision = ?12, desired_replicas = ?13, desired_port = ?14, + desired_tracing = ?15, + observed_revision = ?16, observed_running = ?17, observed_completed = ?18, + observed_finished_at = ?19, observed_state = ?20, observed_node_ids = ?21, observed_error = ?22, + observed_at = ?23, + deployed_revision = ?24, dirty = ?25, shipped_at = ?26, apply_error = ?27, one_shot = ?28, + created_at = ?29 +WHERE id = ?1; + +-- name: CoreDeleteNode :exec +DELETE FROM nodes WHERE id = ?; + +-- name: CoreDeleteEndpointsOfNode :exec +DELETE FROM endpoints WHERE node_id = ?; + +-- name: CoreInsertEndpoint :exec +INSERT INTO endpoints ( + id, node_id, ord, protocol, port, pinned_port, domain, public_port, + status_state, status_error, status_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); diff --git a/internal/adapters/sqlite/sqlc.yaml b/internal/adapters/sqlite/sqlc.yaml new file mode 100644 index 0000000..78b6d1a --- /dev/null +++ b/internal/adapters/sqlite/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: sqlite + schema: migrations + queries: queries + gen: + go: + package: db + out: db + emit_pointers_for_null_types: true + emit_empty_slices: true + emit_methods_with_db_argument: false diff --git a/internal/adapters/sqlite/store.go b/internal/adapters/sqlite/store.go new file mode 100644 index 0000000..d728999 --- /dev/null +++ b/internal/adapters/sqlite/store.go @@ -0,0 +1,189 @@ +// Package sqlite implements app.Store on SQLite (modernc.org/sqlite, pure Go). Schema in +// migrations/, queries in queries/ (sqlc → db/). See docs/go/ARCHITECTURE.md, "Data". +package sqlite + +import ( + "context" + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "sort" + "strings" + "time" + + _ "modernc.org/sqlite" + + "github.com/ThallesP/keel/internal/adapters/sqlite/db" + "github.com/ThallesP/keel/internal/app" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +// Store: one write connection (SQLite has one writer; BEGIN IMMEDIATE takes the lock up front) +// and a pool of readers (WAL lets them run beside the writer). +type Store struct { + w *sql.DB + r *sql.DB +} + +var _ app.Store = (*Store)(nil) + +// Open opens (creating if needed) the database at path and applies pending migrations. ":memory:" +// is not supported (readers and the writer must share the file); tests use a temp file. +func Open(ctx context.Context, path string) (*Store, error) { + dsn := func(lock string) string { + return "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + + "&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_txlock=" + lock + } + w, err := sql.Open("sqlite", dsn("immediate")) + if err != nil { + return nil, err + } + w.SetMaxOpenConns(1) + r, err := sql.Open("sqlite", dsn("deferred")) + if err != nil { + w.Close() + return nil, err + } + r.SetMaxOpenConns(8) + s := &Store{w: w, r: r} + if err := s.migrate(ctx); err != nil { + s.Close() + return nil, fmt.Errorf("migrate %s: %w", path, err) + } + return s, nil +} + +func (s *Store) Close() error { + return errors.Join(s.w.Close(), s.r.Close()) +} + +// DB is the writer, for tooling (import, tests). +func (s *Store) DB() *sql.DB { return s.w } + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil { + return err + } + names, err := fs.Glob(migrations, "migrations/*.sql") + if err != nil { + return err + } + sort.Strings(names) + for _, name := range names { + var n int + if err := s.w.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, name).Scan(&n); err != nil { + return err + } + if n > 0 { + continue + } + body, err := migrations.ReadFile(name) + if err != nil { + return err + } + tx, err := s.w.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, string(body)); err != nil { + tx.Rollback() + return fmt.Errorf("%s: %w", name, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, + name, time.Now().UnixMilli()); err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +func (s *Store) Read(ctx context.Context, fn func(app.Tx) error) error { + return s.run(ctx, s.r, &sql.TxOptions{ReadOnly: true}, fn) +} + +func (s *Store) Write(ctx context.Context, fn func(app.Tx) error) error { + return s.run(ctx, s.w, nil, fn) +} + +func (s *Store) run(ctx context.Context, pool *sql.DB, opts *sql.TxOptions, fn func(app.Tx) error) error { + sqlTx, err := pool.BeginTx(ctx, opts) + if err != nil { + return err + } + t := &tx{ctx: ctx, q: db.New(sqlTx), sql: sqlTx} + if err := fn(t); err != nil { + sqlTx.Rollback() + return err + } + return sqlTx.Commit() +} + +// tx implements app.Tx. Area methods live in .go next to this file. +type tx struct { + ctx context.Context + q *db.Queries + sql *sql.Tx +} + +var _ app.Tx = (*tx)(nil) + +// noRow maps sql.ErrNoRows to app.ErrNoRow and passes anything else through. +func noRow(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return app.ErrNoRow + } + return err +} + +// IsUniqueViolation: a UNIQUE or PRIMARY KEY constraint failed (map it to NAME_TAKEN & co). +func IsUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} + +// Small conversions shared by the area files. + +func b2i(b bool) int64 { + if b { + return 1 + } + return 0 +} + +func ptrInt(p *int64) *int { + if p == nil { + return nil + } + v := int(*p) + return &v +} + +func ptrInt64(p *int) *int64 { + if p == nil { + return nil + } + v := int64(*p) + return &v +} + +func str(p *string) string { + if p == nil { + return "" + } + return *p +} + +func nullStr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..a755ac8 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,41 @@ +// Package api holds the wire types of Keel's HTTP API: request and response bodies and the error +// shape. The server (transport/http) and the CLI (cli) both use them, so a field means the same +// thing on both ends. Stdlib only. JSON names are camelCase; times are unix milliseconds. +package api + +// Problem is every error response: RFC 9457 problem details plus `code`, the stable code callers +// branch on (domain.Code*, the CLI's code vocabulary). +type Problem struct { + Type string `json:"type,omitempty" doc:"A URI reference identifying the problem type" default:"about:blank"` + Title string `json:"title,omitempty" doc:"Short summary of the problem type" example:"Not Found"` + Status int `json:"status,omitempty" doc:"HTTP status code" example:"404"` + Detail string `json:"detail,omitempty" doc:"The human sentence to show" example:"Node not found"` + Code string `json:"code" doc:"Stable error code" example:"SERVICE_NOT_FOUND"` + Errors []ErrorDetail `json:"errors,omitempty" doc:"Validation details"` +} + +// ErrorDetail is one input validation failure. +type ErrorDetail struct { + Message string `json:"message,omitempty"` + Location string `json:"location,omitempty" example:"body.name"` + Value any `json:"value,omitempty"` +} + +func (p *Problem) Error() string { + if p.Detail != "" { + return p.Detail + } + return p.Title +} + +func (p *Problem) GetStatus() int { return p.Status } + +// ContentType: problems are application/problem+json. +func (p *Problem) ContentType(string) string { return "application/problem+json" } + +// Meta is GET /api/meta: what the CLI discovers an install with. +type Meta struct { + Name string `json:"name" example:"keel"` + Version string `json:"version"` + SiteURL string `json:"siteUrl" doc:"Dashboard URL as users open it"` +} diff --git a/internal/app/access.go b/internal/app/access.go new file mode 100644 index 0000000..b16e74c --- /dev/null +++ b/internal/app/access.go @@ -0,0 +1,102 @@ +package app + +import ( + "errors" + + "github.com/ThallesP/keel/internal/domain" +) + +// Access rules (convex/access.ts). Everything a member sees belongs to their organization; a +// foreign or missing row is the same "not found" so ids reveal nothing. + +// EnvScope is an environment the actor may touch, with what it belongs to. +type EnvScope struct { + Org string + Project domain.Project + Environment domain.Environment +} + +// NodeScope is a node the actor may touch. +type NodeScope struct { + EnvScope + Node domain.Node +} + +// ownedProject: the project when it is in the actor's organization, else ok=false. +func ownedProject(tx Tx, actor domain.Actor, id string) (domain.Project, bool, error) { + if !actor.System && actor.OrganizationID == "" { + return domain.Project{}, false, nil + } + p, err := tx.Project(id) + if errors.Is(err, ErrNoRow) { + return domain.Project{}, false, nil + } + if err != nil { + return domain.Project{}, false, err + } + if !actor.System && p.OrganizationID != actor.OrganizationID { + return domain.Project{}, false, nil + } + return p, true, nil +} + +func ownedEnvironment(tx Tx, actor domain.Actor, id string) (EnvScope, bool, error) { + env, err := tx.Environment(id) + if errors.Is(err, ErrNoRow) { + return EnvScope{}, false, nil + } + if err != nil { + return EnvScope{}, false, err + } + p, ok, err := ownedProject(tx, actor, env.ProjectID) + if err != nil || !ok { + return EnvScope{}, false, err + } + return EnvScope{Org: p.OrganizationID, Project: p, Environment: env}, true, nil +} + +func ownedNode(tx Tx, actor domain.Actor, id string) (NodeScope, bool, error) { + n, err := tx.Node(id) + if errors.Is(err, ErrNoRow) { + return NodeScope{}, false, nil + } + if err != nil { + return NodeScope{}, false, err + } + scope, ok, err := ownedEnvironment(tx, actor, n.EnvironmentID) + if err != nil || !ok { + return NodeScope{}, false, err + } + return NodeScope{EnvScope: scope, Node: n}, true, nil +} + +// requireEnvironment: signed out → NOT_AUTHENTICATED; missing/foreign → "Environment not found". +func requireEnvironment(tx Tx, actor domain.Actor, id string) (EnvScope, error) { + if err := actor.RequireUser(); err != nil { + return EnvScope{}, err + } + scope, ok, err := ownedEnvironment(tx, actor, id) + if err != nil { + return EnvScope{}, err + } + if !ok { + return EnvScope{}, domain.NotFound(domain.MsgEnvironmentNotFound) + } + return scope, nil +} + +// requireNode: signed out → NOT_AUTHENTICATED; missing/foreign → "Node not found" (code +// SERVICE_NOT_FOUND, which is what the CLI branches on). +func requireNode(tx Tx, actor domain.Actor, id string) (NodeScope, error) { + if err := actor.RequireUser(); err != nil { + return NodeScope{}, err + } + scope, ok, err := ownedNode(tx, actor, id) + if err != nil { + return NodeScope{}, err + } + if !ok { + return NodeScope{}, domain.E(domain.CodeServiceNotFound, domain.MsgNodeNotFound) + } + return scope, nil +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..1658278 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,72 @@ +// Package app holds Keel's use cases. It depends on domain and on the ports declared in this +// package (ports*.go); adapters implement them and serve wires them. See docs/go/ARCHITECTURE.md. +package app + +import ( + "context" + "log/slog" + "time" +) + +// App is every use case. Fields are ports, set once by serve (or by a test). +type App struct { + Store Store + Events Publisher + Jobs Jobs + Config Config + Log *slog.Logger + // Now is the clock (unix ms). Tests replace it. + Now func() int64 + + // Area ports (declared in ports_.go). + Swarm Swarm + Proxy Proxy + Axiom Axiom +} + +// Config is the environment serve was started with (docs/go/ARCHITECTURE.md, "Env"). +type Config struct { + Version string + SiteURL string // dashboard URL as users open it, no trailing slash + WorkerToken string // bearer for /worker/* and /proxy/events + PublicIP string // KEEL_PUBLIC_IP, "" when unknown + ACMECA string // KEEL_ACME_CA + ACMEEmail string // KEEL_ACME_EMAIL + OTLPURL string // KEEL_OTLP_URL: OTLP relay base injected into traced services + AxiomAuthURL string // KEEL_AXIOM_AUTH_URL override (tests) + AxiomAPIURL string // KEEL_AXIOM_API_URL override (tests) + AllowLocalSinks bool // KEEL_ALLOW_LOCAL_SINKS +} + +// New fills defaults: a no-op publisher, the wall clock, slog.Default. +func New(a App) *App { + if a.Events == nil { + a.Events = noopPublisher{} + } + if a.Now == nil { + a.Now = func() int64 { return time.Now().UnixMilli() } + } + if a.Log == nil { + a.Log = slog.Default() + } + return &a +} + +// read runs fn in a read-only transaction. +func (a *App) read(ctx context.Context, fn func(tx Tx) error) error { + return a.Store.Read(ctx, fn) +} + +// write runs fn in a write transaction and, once it commits, publishes what it changed. +func (a *App) write(ctx context.Context, fn func(tx Tx, ch *Changes) error) error { + ch := &Changes{} + if err := a.Store.Write(ctx, func(tx Tx) error { return fn(tx, ch) }); err != nil { + return err + } + ch.publish(a.Events) + return nil +} + +type noopPublisher struct{} + +func (noopPublisher) Publish(string, []string) {} diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..9ebf53d --- /dev/null +++ b/internal/app/auth.go @@ -0,0 +1,13 @@ +package app + +import ( + "context" + + "github.com/ThallesP/keel/internal/domain" +) + +// ResolveSession turns a session token (cookie or bearer) into the actor. A missing, unknown or +// expired token is the signed-out actor, not an error. Owner: the auth area. +func (a *App) ResolveSession(ctx context.Context, token string) (domain.Actor, error) { + return domain.Actor{}, nil +} diff --git a/internal/app/changes.go b/internal/app/changes.go new file mode 100644 index 0000000..dea0f8e --- /dev/null +++ b/internal/app/changes.go @@ -0,0 +1,66 @@ +package app + +import "sort" + +// Changes collects what a write touched, as URL path prefixes of the API (docs/go/ARCHITECTURE.md, +// "Realtime"). The dashboard refetches every query whose path starts with one of them. +type Changes struct { + byOrg map[string]map[string]struct{} +} + +func (c *Changes) Add(organizationID string, topics ...string) { + if organizationID == "" { + return + } + if c.byOrg == nil { + c.byOrg = map[string]map[string]struct{}{} + } + set := c.byOrg[organizationID] + if set == nil { + set = map[string]struct{}{} + c.byOrg[organizationID] = set + } + for _, t := range topics { + set[t] = struct{}{} + } +} + +// Projects: the project list. +func (c *Changes) Projects(org string) { c.Add(org, "/api/projects") } + +// Project: one project (and the list, which shows it). +func (c *Changes) Project(org, projectID string) { + c.Add(org, "/api/projects") +} + +// Environment: the canvas, summary, deployments and anything else under /api/environments/. +func (c *Changes) Environment(org, environmentID string) { + c.Add(org, "/api/environments/"+environmentID) +} + +// Node: the node's own endpoints (/api/nodes//...) and its environment's canvas. +func (c *Changes) Node(org, environmentID, nodeID string) { + c.Add(org, "/api/nodes/"+nodeID, "/api/environments/"+environmentID) +} + +// Deployment: one deployment and its environment's lists. +func (c *Changes) Deployment(org, environmentID, deploymentID string) { + c.Add(org, "/api/deployments/"+deploymentID, "/api/environments/"+environmentID) +} + +// Organization: members, invitations, the log sink, the current organization. +func (c *Changes) Organization(org string) { c.Add(org, "/api/organization") } + +// Everything: every query of the organization. +func (c *Changes) Everything(org string) { c.Add(org, "/api") } + +func (c *Changes) publish(p Publisher) { + for org, set := range c.byOrg { + topics := make([]string, 0, len(set)) + for t := range set { + topics = append(topics, t) + } + sort.Strings(topics) + p.Publish(org, topics) + } +} diff --git a/internal/app/ports.go b/internal/app/ports.go new file mode 100644 index 0000000..0016027 --- /dev/null +++ b/internal/app/ports.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + "errors" + "time" + + "github.com/ThallesP/keel/internal/domain" +) + +// ErrNoRow: a Tx lookup found nothing. Use cases turn it into a domain error with the message +// the caller should see; it never reaches the transport on its own. +var ErrNoRow = errors.New("no row") + +// Store runs transactions. Writes are serialized (SQLite has one writer), so a read-modify-write +// inside Write is safe. The Tx is bound to the ctx it was opened with. +type Store interface { + Read(ctx context.Context, fn func(Tx) error) error + Write(ctx context.Context, fn func(Tx) error) error +} + +// Tx is everything a use case may do inside one transaction. Each area declares its slice in +// ports_.go and implements it in adapters/sqlite/.go. +type Tx interface { + CoreTx + AuthTx + CanvasTx + DeployTx + ObservabilityTx + IngressTx +} + +// CoreTx: reads and writes every area shares (adapters/sqlite/core.go). +type CoreTx interface { + Project(id string) (domain.Project, error) + Environment(id string) (domain.Environment, error) + // Node and Nodes include the node's endpoints. + Node(id string) (domain.Node, error) + Nodes(environmentID string) ([]domain.Node, error) + AllNodes() ([]domain.Node, error) + OrganizationOfEnvironment(environmentID string) (string, error) + + // InsertNode / UpdateNode write every column of the node except Endpoints. + InsertNode(n domain.Node) error + UpdateNode(n domain.Node) error + DeleteNode(id string) error + // ReplaceEndpoints makes the node's endpoints exactly eps, in order (ids are kept or made). + ReplaceEndpoints(nodeID string, eps []domain.Endpoint) error + + Setting(key string) (string, bool, error) + SetSetting(key, value string) error +} + +// Publisher pushes invalidation topics to an organization's connected dashboards. +type Publisher interface { + Publish(organizationID string, topics []string) +} + +// Jobs replaces Convex's scheduler and crons, in memory. +type Jobs interface { + // After runs fn once after delay. If a job with the same key is already pending, this call is + // dropped (that is the debounce Convex nodes.observeScheduled was for). + After(key string, delay time.Duration, fn func(context.Context)) + // Every runs fn every interval until serve stops. + Every(name string, interval time.Duration, fn func(context.Context)) +} diff --git a/internal/app/ports_auth.go b/internal/app/ports_auth.go new file mode 100644 index 0000000..0447fa6 --- /dev/null +++ b/internal/app/ports_auth.go @@ -0,0 +1,5 @@ +package app + +// AuthTx: users, sessions, organizations, members, invitations, device codes. +// Owner: the auth area (docs/go/spec/auth-orgs.md). +type AuthTx interface{} diff --git a/internal/app/ports_canvas.go b/internal/app/ports_canvas.go new file mode 100644 index 0000000..3e1492e --- /dev/null +++ b/internal/app/ports_canvas.go @@ -0,0 +1,5 @@ +package app + +// CanvasTx: projects, environments, variables (nodes themselves are in CoreTx). +// Owner: the canvas area (docs/go/spec/projects.md). +type CanvasTx interface{} diff --git a/internal/app/ports_deploy.go b/internal/app/ports_deploy.go new file mode 100644 index 0000000..fe03a0f --- /dev/null +++ b/internal/app/ports_deploy.go @@ -0,0 +1,8 @@ +package app + +// DeployTx: deployments, steps, log, cluster. +// Owner: the deploy area (docs/go/spec/projects.md, docs/go/spec/swarm-worker.md). +type DeployTx interface{} + +// Swarm drives Docker Swarm through the manager's socket (adapters/swarm). +type Swarm interface{} diff --git a/internal/app/ports_ingress.go b/internal/app/ports_ingress.go new file mode 100644 index 0000000..ee49deb --- /dev/null +++ b/internal/app/ports_ingress.go @@ -0,0 +1,8 @@ +package app + +// IngressTx: endpoint lookups beyond CoreTx (endpoints are written with ReplaceEndpoints). +// Owner: the ingress area (docs/go/spec/proxy-ingress.md). +type IngressTx interface{} + +// Proxy is keel-proxy's admin API over its unix socket (adapters/caddy). +type Proxy interface{} diff --git a/internal/app/ports_observability.go b/internal/app/ports_observability.go new file mode 100644 index 0000000..80f2a08 --- /dev/null +++ b/internal/app/ports_observability.go @@ -0,0 +1,8 @@ +package app + +// ObservabilityTx: log sinks, Axiom sign-in state, OTLP keys. +// Owner: the observability area (docs/go/spec/observability.md). +type ObservabilityTx interface{} + +// Axiom is Axiom's HTTP APIs: OAuth, datasets, queries, ingest (adapters/axiom). +type Axiom interface{} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..83b5ac5 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,61 @@ +// Package cli is the keel command: the CLI verbs (docs/cli.md) and the server subcommands +// (serve, proxy, agent, openapi). +package cli + +import ( + "context" + "encoding/json" + "os" + + "github.com/spf13/cobra" + + "github.com/ThallesP/keel/internal/serve" + transport "github.com/ThallesP/keel/internal/transport/http" +) + +// Version is set at build time (-ldflags "-X github.com/ThallesP/keel/internal/cli.Version=…"). +var Version = "dev" + +// Server subcommands. The CLI verbs register themselves on the same root (see the cli area). +func serverCommands() []*cobra.Command { + serveCmd := &cobra.Command{ + Use: "serve", + Short: "Run the control plane (API, dashboard, Swarm driver)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return serve.Run(cmd.Context(), serve.Options{Version: Version, Web: WebFS}) + }, + } + openapiCmd := &cobra.Command{ + Use: "openapi", + Short: "Print the OpenAPI document", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(transport.OpenAPI(Version)) + }, + } + return []*cobra.Command{serveCmd, openapiCmd} +} + +// Root is the keel command. +func Root() *cobra.Command { + root := &cobra.Command{ + Use: "keel", + Short: "Keel: deploy containers on your own servers", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(serverCommands()...) + return root +} + +// Execute runs the command line. +func Execute(ctx context.Context) int { + if err := Root().ExecuteContext(ctx); err != nil { + os.Stderr.WriteString("error: " + err.Error() + "\n") + return 1 + } + return 0 +} diff --git a/internal/cli/web.go b/internal/cli/web.go new file mode 100644 index 0000000..43be907 --- /dev/null +++ b/internal/cli/web.go @@ -0,0 +1,6 @@ +package cli + +import "io/fs" + +// WebFS is the built dashboard. main sets it from apps/web (embed) in release builds. +var WebFS fs.FS diff --git a/internal/domain/actor.go b/internal/domain/actor.go new file mode 100644 index 0000000..903fa08 --- /dev/null +++ b/internal/domain/actor.go @@ -0,0 +1,36 @@ +package domain + +// Actor is who is calling a use case. The zero value is signed out. +type Actor struct { + UserID string + Email string + Name string + OrganizationID string // "" when the user has no membership yet + Role string // owner | admin | member + SessionID string + System bool // internal callers: jobs, the agent, the proxy +} + +// SystemActor is used by background jobs and bearer-protected internal routes. +var SystemActor = Actor{System: true} + +func (a Actor) SignedIn() bool { return a.UserID != "" } + +// RequireUser fails with NOT_AUTHENTICATED when signed out. +func (a Actor) RequireUser() error { + if !a.SignedIn() && !a.System { + return ErrNotAuthenticated + } + return nil +} + +// RequireMember fails when signed out or without an organization. +func (a Actor) RequireMember() error { + if err := a.RequireUser(); err != nil { + return err + } + if a.OrganizationID == "" && !a.System { + return ErrNoOrganization + } + return nil +} diff --git a/internal/domain/auth.go b/internal/domain/auth.go new file mode 100644 index 0000000..e8a490a --- /dev/null +++ b/internal/domain/auth.go @@ -0,0 +1,61 @@ +package domain + +// Roles of an organization member. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + CreatedAt int64 `json:"createdAt"` +} + +// Session: Token is the bearer secret (cookie keel_session or Authorization: Bearer). Only its +// hash is stored. +type Session struct { + ID string `json:"id"` + UserID string `json:"userId"` + ExpiresAt int64 `json:"expiresAt"` + CreatedAt int64 `json:"createdAt"` + UserAgent string `json:"userAgent,omitempty"` +} + +// Organization: one per install for now, founded by the first account. +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt int64 `json:"createdAt"` +} + +type Member struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + UserID string `json:"userId"` + Role string `json:"role"` + CreatedAt int64 `json:"createdAt"` +} + +type InvitationStatus string + +const ( + InvitationPending InvitationStatus = "pending" + InvitationAccepted InvitationStatus = "accepted" + InvitationCanceled InvitationStatus = "canceled" +) + +// Invitation: the id is the secret in the invite link. +type Invitation struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + Email string `json:"email"` + Role string `json:"role"` + Status InvitationStatus `json:"status"` + InviterID string `json:"inviterId"` + ExpiresAt int64 `json:"expiresAt"` + CreatedAt int64 `json:"createdAt"` +} diff --git a/internal/domain/deployment.go b/internal/domain/deployment.go new file mode 100644 index 0000000..7c79ba6 --- /dev/null +++ b/internal/domain/deployment.go @@ -0,0 +1,55 @@ +package domain + +type DeploymentStatus string + +const ( + DeploymentRunning DeploymentStatus = "running" + DeploymentSuccess DeploymentStatus = "success" + DeploymentFailed DeploymentStatus = "failed" +) + +type StepStatus string + +const ( + StepPending StepStatus = "pending" + StepRunning StepStatus = "running" + StepDone StepStatus = "done" + StepFailed StepStatus = "failed" +) + +// DeployStep: one per shipped node, plus the final "health checks" step (NodeID ""). +type DeployStep struct { + NodeID string `json:"nodeId,omitempty"` + Label string `json:"label"` + Status StepStatus `json:"status"` + StartedAt *int64 `json:"startedAt,omitempty"` + AppliedAt *int64 `json:"appliedAt,omitempty"` // set once apply created/updated the service + FinishedAt *int64 `json:"finishedAt,omitempty"` +} + +type LogLine struct { + At int64 `json:"at"` + NodeID string `json:"nodeId,omitempty"` + Text string `json:"text"` +} + +type Deployment struct { + ID string `json:"id"` + EnvironmentID string `json:"environmentId"` + Sha string `json:"sha,omitempty"` + Message string `json:"message"` + Status DeploymentStatus `json:"status"` + StartedAt int64 `json:"startedAt"` + FinishedAt *int64 `json:"finishedAt,omitempty"` + Steps []DeployStep `json:"steps"` + Log []LogLine `json:"log"` +} + +// Variable: Value may reference other nodes' variables: ${{ postgres.DATABASE_URL }}. +type Variable struct { + ID string `json:"id"` + NodeID string `json:"nodeId"` + Key string `json:"key"` + Value string `json:"value"` + Secret bool `json:"secret"` +} diff --git a/internal/domain/endpoint.go b/internal/domain/endpoint.go new file mode 100644 index 0000000..0ef3fa2 --- /dev/null +++ b/internal/domain/endpoint.go @@ -0,0 +1,40 @@ +package domain + +// EndpointProtocol: http = https:// on the control plane's 80/443; tcp/udp = +// :. Both → svc-:. See docs/networking.md. +type EndpointProtocol string + +const ( + ProtocolHTTP EndpointProtocol = "http" + ProtocolTCP EndpointProtocol = "tcp" + ProtocolUDP EndpointProtocol = "udp" +) + +type EndpointState string + +const ( + EndpointStarting EndpointState = "starting" + EndpointLive EndpointState = "live" + EndpointFailed EndpointState = "failed" +) + +type EndpointStatus struct { + State EndpointState `json:"state"` + Error string `json:"error,omitempty"` + At int64 `json:"at"` +} + +// Endpoint is one way in from the internet, served by keel-proxy. +type Endpoint struct { + ID string `json:"id"` + NodeID string `json:"nodeId"` + Protocol EndpointProtocol `json:"protocol"` + // The container port the proxy dials. + Port int `json:"port"` + // Expose was given a container port other than the node's; otherwise Port follows the + // node's port each time a change to it ships. + PinnedPort bool `json:"pinnedPort,omitempty"` + Domain string `json:"domain,omitempty"` // http only; unique per install + PublicPort *int `json:"publicPort,omitempty"` // tcp/udp only; unique per protocol + Status EndpointStatus `json:"status"` +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..8a1a73c --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,71 @@ +package domain + +import ( + "errors" + "fmt" +) + +// Error codes. They are the CLI's code vocabulary (docs/cli.md, "Contract"): fields and codes are +// only ever added. The transport maps each to an HTTP status (docs/go/ARCHITECTURE.md, "Errors"). +const ( + CodeNotAuthenticated = "NOT_AUTHENTICATED" + CodeNoOrganization = "NO_ORGANIZATION" + CodeForbidden = "FORBIDDEN" + CodeNotFound = "NOT_FOUND" + CodeProjectNotFound = "PROJECT_NOT_FOUND" + CodeServiceNotFound = "SERVICE_NOT_FOUND" + CodeVariableNotFound = "VARIABLE_NOT_FOUND" + CodeDeploymentNotFound = "DEPLOYMENT_NOT_FOUND" + CodeNameTaken = "NAME_TAKEN" + CodeNothingToShip = "NOTHING_TO_SHIP" + CodeDeploymentRunning = "DEPLOYMENT_RUNNING" + CodeConflict = "CONFLICT" + CodeTracesOff = "TRACES_OFF" + CodeAuthorizationPending = "AUTHORIZATION_PENDING" + CodeInvalidInput = "INVALID_INPUT" + CodeUnavailable = "UNAVAILABLE" + CodeServerError = "SERVER_ERROR" +) + +// Error is a failure the caller should see: a code to branch on and a sentence to show. Messages +// are kept identical to the Convex ConvexError messages they replace. +type Error struct { + Code string + Message string +} + +func (e *Error) Error() string { return e.Message } + +// E builds an *Error. Use the helpers below for the common codes. +func E(code, format string, args ...any) *Error { + if len(args) == 0 { + return &Error{Code: code, Message: format} + } + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} +} + +func Invalid(format string, args ...any) *Error { return E(CodeInvalidInput, format, args...) } +func NotFound(format string, args ...any) *Error { return E(CodeNotFound, format, args...) } +func Conflict(format string, args ...any) *Error { return E(CodeConflict, format, args...) } + +// Messages the dashboard and CLI already know, from convex/access.ts. +const ( + MsgNotAuthenticated = "Not authenticated" + MsgNoOrganization = "You're not in an organization yet. Ask a member for an invite link." + MsgEnvironmentNotFound = "Environment not found" + MsgNodeNotFound = "Node not found" +) + +var ( + ErrNotAuthenticated = &Error{Code: CodeNotAuthenticated, Message: MsgNotAuthenticated} + ErrNoOrganization = &Error{Code: CodeNoOrganization, Message: MsgNoOrganization} +) + +// CodeOf is err's code, or SERVER_ERROR for anything that is not a *Error. +func CodeOf(err error) string { + var e *Error + if errors.As(err, &e) { + return e.Code + } + return CodeServerError +} diff --git a/internal/domain/ids.go b/internal/domain/ids.go new file mode 100644 index 0000000..d16bb95 --- /dev/null +++ b/internal/domain/ids.go @@ -0,0 +1,29 @@ +// Package domain holds Keel's entities, value objects and pure rules. It imports nothing from +// this repository and does no I/O. See docs/go/ARCHITECTURE.md. +package domain + +import ( + "crypto/rand" + "encoding/base32" + "strings" +) + +var idEncoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) + +// NewID is a 20-character random lowercase id (100 bits). Every row id is one. +func NewID() string { + b := make([]byte, 13) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand never fails on supported platforms + } + return idEncoding.EncodeToString(b)[:20] +} + +// NewSecret is a URL-safe random token with n bytes of entropy (session tokens, ingest keys). +func NewSecret(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return strings.ToLower(idEncoding.EncodeToString(b)) +} diff --git a/internal/domain/node.go b/internal/domain/node.go new file mode 100644 index 0000000..50668e7 --- /dev/null +++ b/internal/domain/node.go @@ -0,0 +1,103 @@ +package domain + +// NodeType is what a canvas node is. volume and group never reach Swarm in v1. +type NodeType string + +const ( + NodeService NodeType = "service" + NodeDatabase NodeType = "database" + NodeCache NodeType = "cache" + NodeVolume NodeType = "volume" + NodeGroup NodeType = "group" +) + +// Deployable: types that become a Swarm service. +func (t NodeType) Deployable() bool { + return t == NodeService || t == NodeDatabase || t == NodeCache +} + +func (t NodeType) Valid() bool { + switch t { + case NodeService, NodeDatabase, NodeCache, NodeVolume, NodeGroup: + return true + } + return false +} + +type Position struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// NodeConfig: type-specific, non-deploy settings. Volumes: sizeGb. Groups: width/height. +type NodeConfig struct { + SizeGb *float64 `json:"sizeGb,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` +} + +// Desired is what the user asked for. Revision 0 means never shipped. Env is computed at apply +// time from variables, so it is not stored here. +type Desired struct { + Image string `json:"image"` + Revision int `json:"revision"` + Replicas int `json:"replicas"` + Port *int `json:"port,omitempty"` + Tracing bool `json:"tracing,omitempty"` +} + +// ObservedState is what Swarm reports about the current revision. +type ObservedState string + +const ( + ObservedOK ObservedState = "ok" + ObservedUpdating ObservedState = "updating" + ObservedCrashloop ObservedState = "crashloop" + ObservedPending ObservedState = "pending" + ObservedFailed ObservedState = "failed" + ObservedCompleted ObservedState = "completed" +) + +// Observed is written only by observation (swarm scans). See convex/schema.ts for the meaning of +// `failed` (update rolled back/paused) and `completed` (every task of the revision exited 0). +type Observed struct { + Revision int `json:"revision"` + Running int `json:"running"` + Completed *int `json:"completed,omitempty"` + FinishedAt *int64 `json:"finishedAt,omitempty"` + State ObservedState `json:"state"` + NodeIDs []string `json:"nodeIds"` + Error string `json:"error,omitempty"` + At int64 `json:"at"` +} + +// Node is a canvas node: service, database, cache, volume or group. +type Node struct { + ID string `json:"id"` + EnvironmentID string `json:"environmentId"` + Type NodeType `json:"type"` + Name string `json:"name"` + ParentID string `json:"parentId,omitempty"` + Position Position `json:"position"` + Config NodeConfig `json:"config"` + Desired *Desired `json:"desired,omitempty"` + Observed *Observed `json:"observed,omitempty"` + Endpoints []Endpoint `json:"endpoints,omitempty"` + // Last revision observation saw fully converged. + DeployedRevision *int `json:"deployedRevision,omitempty"` + // Config or variables (or a variable it references) changed since the last ship. + Dirty bool `json:"dirty,omitempty"` + // When the current desired.revision was shipped (unix ms). + ShippedAt *int64 `json:"shippedAt,omitempty"` + // Last apply failure (pull error, bad spec) or a deployment timeout. Cleared on the next ship. + ApplyError string `json:"applyError,omitempty"` + // The last run exited 0 and nothing kept running (one-shot image). + OneShot bool `json:"oneShot,omitempty"` + CreatedAt int64 `json:"createdAt"` +} + +// ServiceName is the Swarm service of a node. +func (n Node) ServiceName() string { return ServicePrefix + n.ID } + +// ServicePrefix names every Keel-managed Swarm service: svc-. +const ServicePrefix = "svc-" diff --git a/internal/domain/project.go b/internal/domain/project.go new file mode 100644 index 0000000..b0d7621 --- /dev/null +++ b/internal/domain/project.go @@ -0,0 +1,18 @@ +package domain + +// Project belongs to one organization and always has a production environment. +type Project struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt int64 `json:"createdAt"` +} + +type Environment struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Name string `json:"name"` + IsProduction bool `json:"isProduction"` + CreatedAt int64 `json:"createdAt"` +} diff --git a/internal/domain/status.go b/internal/domain/status.go new file mode 100644 index 0000000..b77a2f7 --- /dev/null +++ b/internal/domain/status.go @@ -0,0 +1,76 @@ +package domain + +// NodeStatus is what the canvas shows for a node (convex/status.ts). +// +// done: a one-shot image ran and every task exited 0. +// stopping: scaled to 0, Swarm has not yet confirmed every task is gone. +type NodeStatus string + +const ( + StatusHealthy NodeStatus = "healthy" + StatusDone NodeStatus = "done" + StatusDeploying NodeStatus = "deploying" + StatusStopping NodeStatus = "stopping" + StatusError NodeStatus = "error" + StatusStopped NodeStatus = "stopped" + StatusPending NodeStatus = "pending" +) + +// Converged: observed matches desired and every replica runs, or every replica ran to completion. +func Converged(desired *Desired, observed *Observed) bool { + if desired == nil || observed == nil { + return false + } + if desired.Replicas == 0 { + return observed.Running == 0 + } + if observed.Revision != desired.Revision { + return false + } + if observed.State == ObservedCompleted { + completed := 0 + if observed.Completed != nil { + completed = *observed.Completed + } + return completed >= desired.Replicas + } + return observed.State == ObservedOK && observed.Running >= desired.Replicas +} + +// DeriveStatus mirrors convex/status.ts deriveStatus exactly. +func DeriveStatus(n Node) NodeStatus { + d, o := n.Desired, n.Observed + if d == nil || d.Revision == 0 { + return StatusPending // never shipped + } + if n.ApplyError != "" { + return StatusError // pull / spec failure; cleared on the next ship + } + if o == nil { + return StatusDeploying // shipped, observe has not seen it yet + } + // At 0 replicas the service spec label still carries the revision, so observe catches up even + // though Swarm drops the task history. Revision 0 means the service itself is gone. + if d.Replicas == 0 { + if o.Running > 0 { + return StatusStopping + } + if o.Revision == 0 || o.Revision >= d.Revision { + return StatusStopped + } + return StatusStopping + } + if o.Revision < d.Revision { + return StatusDeploying + } + if o.State == ObservedCrashloop || o.State == ObservedFailed { + return StatusError + } + if Converged(d, o) { + if o.State == ObservedCompleted { + return StatusDone + } + return StatusHealthy + } + return StatusDeploying +} diff --git a/internal/domain/status_test.go b/internal/domain/status_test.go new file mode 100644 index 0000000..e62ed2d --- /dev/null +++ b/internal/domain/status_test.go @@ -0,0 +1,38 @@ +package domain + +import "testing" + +func ip(i int) *int { return &i } + +func TestDeriveStatus(t *testing.T) { + cases := []struct { + name string + n Node + want NodeStatus + }{ + {"never shipped", Node{}, StatusPending}, + {"revision 0", Node{Desired: &Desired{Revision: 0}}, StatusPending}, + {"apply error", Node{Desired: &Desired{Revision: 1, Replicas: 1}, ApplyError: "pull"}, StatusError}, + {"not observed", Node{Desired: &Desired{Revision: 1, Replicas: 1}}, StatusDeploying}, + {"healthy", Node{Desired: &Desired{Revision: 2, Replicas: 1}, Observed: &Observed{Revision: 2, Running: 1, State: ObservedOK}}, StatusHealthy}, + {"old revision", Node{Desired: &Desired{Revision: 2, Replicas: 1}, Observed: &Observed{Revision: 1, Running: 1, State: ObservedOK}}, StatusDeploying}, + {"crashloop", Node{Desired: &Desired{Revision: 2, Replicas: 1}, Observed: &Observed{Revision: 2, State: ObservedCrashloop}}, StatusError}, + {"one-shot done", Node{Desired: &Desired{Revision: 2, Replicas: 1}, Observed: &Observed{Revision: 2, State: ObservedCompleted, Completed: ip(1)}}, StatusDone}, + {"scaled to 0, running", Node{Desired: &Desired{Revision: 3, Replicas: 0}, Observed: &Observed{Revision: 3, Running: 1}}, StatusStopping}, + {"scaled to 0, gone", Node{Desired: &Desired{Revision: 3, Replicas: 0}, Observed: &Observed{Revision: 0}}, StatusStopped}, + {"scaled to 0, behind", Node{Desired: &Desired{Revision: 3, Replicas: 0}, Observed: &Observed{Revision: 2}}, StatusStopping}, + } + for _, c := range cases { + if got := DeriveStatus(c.n); got != c.want { + t.Errorf("%s: got %s, want %s", c.name, got, c.want) + } + } +} + +func TestSlug(t *testing.T) { + for in, want := range map[string]string{"My API": "my-api", " Ação! ": "acao", "---": "", "Acme_Support 2": "acme-support-2"} { + if got := Slug(in); got != want { + t.Errorf("Slug(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/domain/validate.go b/internal/domain/validate.go new file mode 100644 index 0000000..e375eae --- /dev/null +++ b/internal/domain/validate.go @@ -0,0 +1,72 @@ +package domain + +import ( + "regexp" + "strings" + + "golang.org/x/text/unicode/norm" +) + +var ( + nameRE = regexp.MustCompile(`^[a-z0-9-]{1,40}$`) + envKeyRE = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`) + imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._\-/:@]{0,199}$`) +) + +// ValidName: node names (convex/access.ts validName). +func ValidName(name string) error { + if !nameRE.MatchString(name) { + return Invalid("Name: 1–40 chars, a-z 0-9 and - only") + } + return nil +} + +// ValidEnvKey: variable keys. +func ValidEnvKey(key string) error { + if !envKeyRE.MatchString(key) { + return Invalid("Key: UPPER_SNAKE_CASE only") + } + return nil +} + +// ValidImage: image references only; never a command, mount or socket. +func ValidImage(image string) error { + if !imageRE.MatchString(image) { + return Invalid("Image must look like repo/name:tag") + } + return nil +} + +// ValidPort accepts nil (no port). +func ValidPort(port *int) error { + if port != nil && (*port < 1 || *port > 65535) { + return Invalid("Port must be 1–65535") + } + return nil +} + +// ValidReplicas accepts nil (unchanged). +func ValidReplicas(replicas *int) error { + if replicas != nil && (*replicas < 0 || *replicas > 20) { + return Invalid("Replicas must be 0–20") + } + return nil +} + +var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) + +// Slug: "My API" → "my-api" (convex/projects.ts slugOf). Empty when nothing usable is left. +func Slug(name string) string { + var b strings.Builder + for _, r := range norm.NFKD.String(name) { + if r >= 0x300 && r <= 0x36f { // combining diacritics, as the JS slugOf strips them + continue + } + b.WriteRune(r) + } + s := nonSlug.ReplaceAllString(strings.ToLower(b.String()), "-") + if len(s) > 40 { + s = s[:40] + } + return strings.Trim(s, "-") +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go new file mode 100644 index 0000000..0e766fb --- /dev/null +++ b/internal/serve/serve.go @@ -0,0 +1,92 @@ +// Package serve wires `keel serve`: config from the environment, adapters, the app, the HTTP +// server, background jobs. Nothing else constructs adapters. +package serve + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ThallesP/keel/internal/adapters/sqlite" + "github.com/ThallesP/keel/internal/app" + transport "github.com/ThallesP/keel/internal/transport/http" +) + +// Options are what main passes in; everything else comes from the environment. +type Options struct { + Version string + Web fs.FS // the embedded dashboard, nil in dev builds +} + +// Env reads a variable with a default. +func Env(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +// ConfigFromEnv is app.Config from the environment (docs/go/ARCHITECTURE.md, "Env"). +func ConfigFromEnv(version string) app.Config { + return app.Config{ + Version: version, + SiteURL: strings.TrimRight(Env("KEEL_SITE_URL", ""), "/"), + WorkerToken: Env("KEEL_WORKER_TOKEN", ""), + PublicIP: Env("KEEL_PUBLIC_IP", ""), + ACMECA: Env("KEEL_ACME_CA", ""), + ACMEEmail: Env("KEEL_ACME_EMAIL", ""), + OTLPURL: strings.TrimRight(Env("KEEL_OTLP_URL", ""), "/"), + AxiomAuthURL: Env("KEEL_AXIOM_AUTH_URL", ""), + AxiomAPIURL: Env("KEEL_AXIOM_API_URL", ""), + AllowLocalSinks: Env("KEEL_ALLOW_LOCAL_SINKS", "") == "1", + } +} + +// Run serves until ctx is cancelled. +func Run(ctx context.Context, opts Options) error { + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + slog.SetDefault(log) + cfg := ConfigFromEnv(opts.Version) + + dataDir := Env("KEEL_DATA_DIR", "/data") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return err + } + store, err := sqlite.Open(ctx, filepath.Join(dataDir, "keel.db")) + if err != nil { + return err + } + defer store.Close() + + a := app.New(app.App{Store: store, Config: cfg, Log: log}) + handler := transport.New(a, transport.Options{Web: opts.Web, ConfigJS: "window.__KEEL_CONFIG__ = {};\n"}) + + listen := Env("KEEL_LISTEN", ":8080") + ln, err := net.Listen("tcp", listen) + if err != nil { + return err + } + srv := &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second} + log.Info("keel serve", "listen", ln.Addr().String(), "data", dataDir, "version", opts.Version) + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdown) + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve: %w", err) + } +} diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go new file mode 100644 index 0000000..89f9368 --- /dev/null +++ b/internal/transport/http/auth.go @@ -0,0 +1,6 @@ +package http + +import "github.com/danielgtaylor/huma/v2" + +// Owner: the auth area. +func (s *Server) registerAuth(h huma.API) {} diff --git a/internal/transport/http/canvas.go b/internal/transport/http/canvas.go new file mode 100644 index 0000000..5581e73 --- /dev/null +++ b/internal/transport/http/canvas.go @@ -0,0 +1,6 @@ +package http + +import "github.com/danielgtaylor/huma/v2" + +// Owner: the canvas area. +func (s *Server) registerCanvas(h huma.API) {} diff --git a/internal/transport/http/deploy.go b/internal/transport/http/deploy.go new file mode 100644 index 0000000..d69d870 --- /dev/null +++ b/internal/transport/http/deploy.go @@ -0,0 +1,6 @@ +package http + +import "github.com/danielgtaylor/huma/v2" + +// Owner: the deploy area. +func (s *Server) registerDeploy(h huma.API) {} diff --git a/internal/transport/http/deploy_raw.go b/internal/transport/http/deploy_raw.go new file mode 100644 index 0000000..fd1d680 --- /dev/null +++ b/internal/transport/http/deploy_raw.go @@ -0,0 +1,6 @@ +package http + +import "net/http" + +// Raw (non-Huma) routes. Owner: the deploy area. +func (s *Server) registerDeployRaw(mux *http.ServeMux) {} diff --git a/internal/transport/http/ingress.go b/internal/transport/http/ingress.go new file mode 100644 index 0000000..5defd72 --- /dev/null +++ b/internal/transport/http/ingress.go @@ -0,0 +1,6 @@ +package http + +import "github.com/danielgtaylor/huma/v2" + +// Owner: the ingress area. +func (s *Server) registerIngress(h huma.API) {} diff --git a/internal/transport/http/ingress_raw.go b/internal/transport/http/ingress_raw.go new file mode 100644 index 0000000..c901fc1 --- /dev/null +++ b/internal/transport/http/ingress_raw.go @@ -0,0 +1,6 @@ +package http + +import "net/http" + +// Raw (non-Huma) routes. Owner: the ingress area. +func (s *Server) registerIngressRaw(mux *http.ServeMux) {} diff --git a/internal/transport/http/meta.go b/internal/transport/http/meta.go new file mode 100644 index 0000000..6f2af6a --- /dev/null +++ b/internal/transport/http/meta.go @@ -0,0 +1,21 @@ +package http + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/ThallesP/keel/internal/api" +) + +type metaOutput struct{ Body api.Meta } + +func (s *Server) registerMeta(h huma.API) { + op(h, huma.Operation{ + OperationID: "getMeta", Method: http.MethodGet, Path: "/api/meta", Tags: []string{"meta"}, + Summary: "What this install is", Security: []map[string][]string{}, + }, func(ctx context.Context, _ *struct{}) (*metaOutput, error) { + return &metaOutput{Body: api.Meta{Name: "keel", Version: s.app.Config.Version, SiteURL: s.app.Config.SiteURL}}, nil + }) +} diff --git a/internal/transport/http/observability.go b/internal/transport/http/observability.go new file mode 100644 index 0000000..14a1634 --- /dev/null +++ b/internal/transport/http/observability.go @@ -0,0 +1,6 @@ +package http + +import "github.com/danielgtaylor/huma/v2" + +// Owner: the observability area. +func (s *Server) registerObservability(h huma.API) {} diff --git a/internal/transport/http/observability_raw.go b/internal/transport/http/observability_raw.go new file mode 100644 index 0000000..70ed2e2 --- /dev/null +++ b/internal/transport/http/observability_raw.go @@ -0,0 +1,6 @@ +package http + +import "net/http" + +// Raw (non-Huma) routes. Owner: the observability area. +func (s *Server) registerObservabilityRaw(mux *http.ServeMux) {} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go new file mode 100644 index 0000000..3260923 --- /dev/null +++ b/internal/transport/http/server.go @@ -0,0 +1,248 @@ +// Package http is Keel's HTTP transport: the Huma API under /api, the WebSocket, the raw routes +// agents and services call, and the dashboard. Handlers translate wire types to use-case calls and +// back; all logic is in app. See docs/go/ARCHITECTURE.md, "HTTP API". +package http + +import ( + "context" + "errors" + "io/fs" + "log/slog" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humago" + + "github.com/ThallesP/keel/internal/api" + "github.com/ThallesP/keel/internal/app" + "github.com/ThallesP/keel/internal/domain" +) + +// Options configure the server beyond the app. +type Options struct { + // Web is the built dashboard (apps/web/dist), served with an SPA fallback. nil in dev: Vite + // serves it and proxies the API here. + Web fs.FS + // WS is the WebSocket handler mounted at /api/ws (adapters/realtime). nil disables it. + WS http.Handler + // ConfigJS is the body of /config.js. + ConfigJS string +} + +// Server holds what handlers need. +type Server struct { + app *app.App + opts Options + log *slog.Logger +} + +// SessionCookie is the dashboard's session cookie. +const SessionCookie = "keel_session" + +func init() { + // Every error the API writes is an api.Problem, so the OpenAPI document (and the dashboard's + // generated types) describe exactly that shape. + huma.NewError = func(status int, msg string, errs ...error) huma.StatusError { + p := &api.Problem{Status: status, Title: http.StatusText(status), Detail: msg, Code: codeForStatus(status)} + for _, e := range errs { + var d *huma.ErrorDetail + if errors.As(e, &d) { + p.Errors = append(p.Errors, api.ErrorDetail{Message: d.Message, Location: d.Location, Value: d.Value}) + } else if e != nil && p.Detail == "" { + p.Detail = e.Error() + } + } + if status == http.StatusUnprocessableEntity || status == http.StatusBadRequest { + p.Code = domain.CodeInvalidInput + } + return p + } + huma.NewErrorWithContext = func(_ huma.Context, status int, msg string, errs ...error) huma.StatusError { + return huma.NewError(status, msg, errs...) + } +} + +// Config is the Huma configuration, shared by the server and `keel openapi`. +func Config(version string) huma.Config { + c := huma.DefaultConfig("Keel", version) + c.Info.Description = "Keel control plane API. Errors are application/problem+json with a stable `code`." + c.OpenAPIPath = "/api/openapi" + c.DocsPath = "/api/docs" + c.SchemasPath = "/api/schemas" + // No `$schema` link in response bodies: it would leak into the dashboard's generated types. + c.CreateHooks = nil + c.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "session": {Type: "apiKey", In: "cookie", Name: SessionCookie}, + "bearer": {Type: "http", Scheme: "bearer"}, + } + c.Security = []map[string][]string{{"session": {}}, {"bearer": {}}} + return c +} + +// New builds the whole HTTP handler. +func New(a *app.App, opts Options) http.Handler { + s := &Server{app: a, opts: opts, log: a.Log} + mux := http.NewServeMux() + humaAPI := humago.New(mux, Config(a.Config.Version)) + s.Register(humaAPI) + s.registerRaw(mux) + return s.withActor(mux) +} + +// Register adds every Huma operation. `keel openapi` calls it with a nil app to print the spec. +func (s *Server) Register(h huma.API) { + s.registerMeta(h) + s.registerAuth(h) + s.registerCanvas(h) + s.registerDeploy(h) + s.registerObservability(h) + s.registerIngress(h) +} + +// OpenAPI is the API document without a running server. +func OpenAPI(version string) *huma.OpenAPI { + mux := http.NewServeMux() + h := humago.New(mux, Config(version)) + (&Server{app: &app.App{Config: app.Config{Version: version}}}).Register(h) + return h.OpenAPI() +} + +// registerRaw adds the non-Huma routes: agent and proxy callbacks, OTLP, WebSocket, config.js, +// and the dashboard. +func (s *Server) registerRaw(mux *http.ServeMux) { + s.registerDeployRaw(mux) + s.registerObservabilityRaw(mux) + s.registerIngressRaw(mux) + if s.opts.WS != nil { + mux.Handle("GET /api/ws", s.opts.WS) + } + mux.HandleFunc("GET /config.js", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(s.opts.ConfigJS)) + }) + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + writeProblem(w, &api.Problem{Status: 404, Title: "Not Found", Detail: "No such API route", Code: domain.CodeNotFound}) + }) + if s.opts.Web != nil { + mux.Handle("/", spa(s.opts.Web)) + } +} + +// ── Actor ────────────────────────────────────────────────────────────────────────────────── + +type actorKey struct{} + +// withActor resolves the session (cookie, else bearer) once per request. +func (s *Server) withActor(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := SessionToken(r) + actor := domain.Actor{} + if token != "" && s.app != nil && s.app.Store != nil { + a, err := s.app.ResolveSession(r.Context(), token) + if err != nil { + s.log.Error("resolve session", "err", err) + } else { + actor = a + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, actor))) + }) +} + +// SessionToken: the keel_session cookie, else `Authorization: Bearer `. +func SessionToken(r *http.Request) string { + if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" { + return c.Value + } + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } + return "" +} + +// ActorFrom is the caller of the current request. +func ActorFrom(ctx context.Context) domain.Actor { + a, _ := ctx.Value(actorKey{}).(domain.Actor) + return a +} + +// ── Operations and errors ────────────────────────────────────────────────────────────────── + +// op registers a Huma operation whose handler returns domain errors; they become problems. +func op[I, O any](h huma.API, o huma.Operation, handler func(ctx context.Context, in *I) (*O, error)) { + huma.Register(h, o, func(ctx context.Context, in *I) (*O, error) { + out, err := handler(ctx, in) + if err != nil { + return nil, problemOf(err) + } + return out, nil + }) +} + +// problemOf maps a domain error to its problem; anything else is a logged 500. +func problemOf(err error) error { + var p *api.Problem + if errors.As(err, &p) { + return p + } + var de *domain.Error + if errors.As(err, &de) { + status := StatusOf(de.Code) + return &api.Problem{Status: status, Title: http.StatusText(status), Detail: de.Message, Code: de.Code} + } + if errors.Is(err, context.Canceled) { + return &api.Problem{Status: 499, Title: "Client Closed Request", Detail: "Cancelled", Code: domain.CodeServerError} + } + slog.Error("unexpected error", "err", err) + return &api.Problem{Status: 500, Title: "Internal Server Error", Detail: "Something went wrong on the server", Code: domain.CodeServerError} +} + +// StatusOf is the HTTP status of a domain error code (docs/go/ARCHITECTURE.md, "Errors"). +func StatusOf(code string) int { + switch code { + case domain.CodeNotAuthenticated: + return 401 + case domain.CodeNoOrganization, domain.CodeForbidden: + return 403 + case domain.CodeNotFound, domain.CodeProjectNotFound, domain.CodeServiceNotFound, + domain.CodeVariableNotFound, domain.CodeDeploymentNotFound: + return 404 + case domain.CodeNameTaken, domain.CodeNothingToShip, domain.CodeDeploymentRunning, + domain.CodeConflict, domain.CodeTracesOff: + return 409 + case domain.CodeAuthorizationPending: + return 428 + case domain.CodeInvalidInput: + return 422 + case domain.CodeUnavailable: + return 503 + } + return 500 +} + +func codeForStatus(status int) string { + switch status { + case 401: + return domain.CodeNotAuthenticated + case 403: + return domain.CodeForbidden + case 404: + return domain.CodeNotFound + case 409: + return domain.CodeConflict + case 400, 422: + return domain.CodeInvalidInput + case 503: + return domain.CodeUnavailable + } + return domain.CodeServerError +} + +// writeProblem writes a problem from a raw (non-Huma) handler. +func writeProblem(w http.ResponseWriter, p *api.Problem) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(p.Status) + _ = jsonEncode(w, p) +} diff --git a/internal/transport/http/util.go b/internal/transport/http/util.go new file mode 100644 index 0000000..c19f674 --- /dev/null +++ b/internal/transport/http/util.go @@ -0,0 +1,30 @@ +package http + +import ( + "encoding/json" + "io" + "io/fs" + "net/http" + "path" + "strings" +) + +func jsonEncode(w io.Writer, v any) error { return json.NewEncoder(w).Encode(v) } + +// spa serves files from web and falls back to index.html for client routes. +func spa(web fs.FS) http.Handler { + files := http.FileServer(http.FS(web)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if name == "" { + name = "index.html" + } + if _, err := fs.Stat(web, name); err != nil { + r = r.Clone(r.Context()) + r.URL.Path = "/" + } else if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + files.ServeHTTP(w, r) + }) +} From 5f6d2623479fa0a001396be8f0f261484d8c23b2 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:25:49 +0000 Subject: [PATCH 002/249] Go: cross-area seams, Docker client, implementation guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/go/ARCHITECTURE.md | 29 ++++++++++ go.mod | 19 +++++++ go.sum | 39 +++++++++++++ internal/adapters/swarm/swarm.go | 27 +++++++++ internal/app/app.go | 1 + internal/app/changes.go | 10 ++++ internal/app/ports_observability.go | 4 ++ internal/app/seams.go | 87 +++++++++++++++++++++++++++++ 8 files changed, 216 insertions(+) create mode 100644 internal/adapters/swarm/swarm.go create mode 100644 internal/app/seams.go diff --git a/docs/go/ARCHITECTURE.md b/docs/go/ARCHITECTURE.md index ad53c93..6896fe9 100644 --- a/docs/go/ARCHITECTURE.md +++ b/docs/go/ARCHITECTURE.md @@ -156,3 +156,32 @@ HTTP status and writes RFC 9457 `application/problem+json` with the extra field a Docker-in-Docker Swarm in CI, never the dev box's shared Swarm. - `go vet ./...`, `gofmt`, `go test ./...` must pass. `bun run check-types` + `bun run build` in `apps/web` must pass. + +## Building an area (implementation guide) + +The port is split into areas that are built in parallel, each in its own git worktree, then +merged. Rules that keep the merge mechanical: + +- **Own your files.** An area owns `internal/app/*.go`, `internal/app/ports_.go`, + `internal/adapters/sqlite/.go` + `queries/.sql`, `internal/transport/http/*.go`, + `internal/api/.go`, plus any adapter package it is assigned. Don't edit other areas' files. +- **Seams** (`internal/app/seams.go`): cross-area functions as stubs. Implement the ones you own by + moving them into your own file and deleting them from `seams.go` (that file is the only shared + one you may edit, and only to delete your stubs). Call the others as if they worked; their + tests are theirs. +- **Shared files you may append to, never rewrite:** `go.mod`/`go.sum` (`go get`), new migration + files `migrations/0002_.sql` (additive, only if 0001 truly lacks something; say why in + your report), `domain/` (new files, or new fields with a comment). +- **sqlc:** query names start with the area (`AuthGetUser`, `CanvasInsertProject`, + `DeployListRunning`, `ObsGetSink`, `IngressListHTTP`). ASCII only in `queries/*.sql`. Regenerate + with `cd internal/adapters/sqlite && ~/go/bin/sqlc generate`; commit the generated `db/` files. +- **Routes and operationIds** follow `docs/go/spec/web-data.md` §4 and §10 (the dashboard is + rewritten against them), and the path prefixes in "Realtime" above. Every write calls the right + `Changes` helper (web-data.md §10.3). +- **Messages** are the Convex strings from the specs, verbatim. Codes from `domain`. +- **Tests:** domain rules as table tests; use cases against `sqlite.OpenTest`-style temp databases + with hand-written fakes for Swarm/Proxy/Axiom ports. No network, no real Docker in unit tests. +- **Done means:** `go build ./... && go vet ./... && go test ./...` green, `gofmt -l` empty, work + committed on your branch. + +Toolchain: `export PATH=$HOME/.local/go/bin:$HOME/go/bin:$PATH` (Go 1.27 via GOTOOLCHAIN=auto). diff --git a/go.mod b/go.mod index 91d1af8..5c19848 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,32 @@ require ( ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.8.1 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.56.1 // indirect + github.com/moby/moby/client v0.6.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/sys v0.48.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 5da6da6..179ede4 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,33 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,8 +38,18 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= +github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.1 h1:gvfKmdcWw+9MzXYP0vAUKQtBTGu1L8475K2nsmL7O98= +github.com/moby/moby/client v0.6.1/go.mod h1:XHgTFqz9NCgS/VuoxXMmEDVmVgXQ4vFEc15wsCIwb24= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -35,6 +64,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= diff --git a/internal/adapters/swarm/swarm.go b/internal/adapters/swarm/swarm.go new file mode 100644 index 0000000..57a620a --- /dev/null +++ b/internal/adapters/swarm/swarm.go @@ -0,0 +1,27 @@ +// Package swarm drives Docker Swarm through the manager's Docker socket (moby client). It +// implements app.Swarm (deploy area: apply, observe, nodes) and app.LogReader (observability area: +// service logs). Files: swarm.go (client), apply.go/observe.go (deploy), logs.go (observability). +package swarm + +import ( + "github.com/moby/moby/client" +) + +// Swarm is the Docker client of the control plane. +type Swarm struct { + cli *client.Client +} + +// New connects to DOCKER_HOST (default unix:///var/run/docker.sock). +func New() (*Swarm, error) { + cli, err := client.New(client.FromEnv) + if err != nil { + return nil, err + } + return &Swarm{cli: cli}, nil +} + +// Client is the underlying moby client. +func (s *Swarm) Client() *client.Client { return s.cli } + +func (s *Swarm) Close() error { return s.cli.Close() } diff --git a/internal/app/app.go b/internal/app/app.go index 1658278..2bcf9dd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ type App struct { // Area ports (declared in ports_.go). Swarm Swarm + Logs LogReader Proxy Proxy Axiom Axiom } diff --git a/internal/app/changes.go b/internal/app/changes.go index dea0f8e..d482303 100644 --- a/internal/app/changes.go +++ b/internal/app/changes.go @@ -6,8 +6,13 @@ import "sort" // "Realtime"). The dashboard refetches every query whose path starts with one of them. type Changes struct { byOrg map[string]map[string]struct{} + after []func() } +// AfterCommit runs fn once the transaction has committed (and only then): schedule jobs, start +// applies, kick a proxy sync. Convex's scheduler.runAfter(0, ...) inside a mutation is this. +func (c *Changes) AfterCommit(fn func()) { c.after = append(c.after, fn) } + func (c *Changes) Add(organizationID string, topics ...string) { if organizationID == "" { return @@ -55,6 +60,11 @@ func (c *Changes) Organization(org string) { c.Add(org, "/api/organization") } func (c *Changes) Everything(org string) { c.Add(org, "/api") } func (c *Changes) publish(p Publisher) { + defer func() { + for _, fn := range c.after { + fn() + } + }() for org, set := range c.byOrg { topics := make([]string, 0, len(set)) for t := range set { diff --git a/internal/app/ports_observability.go b/internal/app/ports_observability.go index 80f2a08..5c66825 100644 --- a/internal/app/ports_observability.go +++ b/internal/app/ports_observability.go @@ -6,3 +6,7 @@ type ObservabilityTx interface{} // Axiom is Axiom's HTTP APIs: OAuth, datasets, queries, ingest (adapters/axiom). type Axiom interface{} + +// LogReader reads container logs from Docker (the default sink: `docker service logs` on the +// manager). Implemented by adapters/swarm/logs.go. +type LogReader interface{} diff --git a/internal/app/seams.go b/internal/app/seams.go new file mode 100644 index 0000000..e3e18ef --- /dev/null +++ b/internal/app/seams.go @@ -0,0 +1,87 @@ +package app + +// Cross-area seams. Each function here is called by one area and implemented by another; they are +// stubs in the foundation commit so areas can be built in parallel. The owning area REPLACES the +// stub by moving the function into its own file (and deleting it from here). + +import ( + "context" + + "github.com/ThallesP/keel/internal/domain" +) + +// joinOrFound is the actor's membership, founding the install's organization when there is none +// yet (convex/projects.ts joinOrFound). Returns the actor with OrganizationID/Role set. +// Errors: NOT_AUTHENTICATED; NO_ORGANIZATION when an organization exists and the actor is not in +// it. Called by: canvas (EnsureDefaultProject, CreateProject). Owner: auth. +func joinOrFound(tx Tx, actor domain.Actor, now int64) (domain.Actor, error) { + panic("joinOrFound: implemented by the auth area") +} + +// ShipOptions are beginDeployment's options (docs/go/spec/projects.md §7.1). +type ShipOptions struct { + Only []string // nil = every dirty deployable node; non-nil (even empty) = exactly these + Refresh bool // pull the image again + Verb string // message word override ("start", "stop"); "" derives ship/deploy/redeploy +} + +// beginDeployment starts a deployment of the environment inside the caller's write transaction +// and registers the applies + timeout with ch.AfterCommit. Errors: DEPLOYMENT_RUNNING, +// NOTHING_TO_SHIP. Called by: canvas (nodes create{deploy}/start/stop), deploy (Ship). +// Owner: deploy. +func (a *App) beginDeployment(tx Tx, ch *Changes, scope EnvScope, opts ShipOptions) (string, error) { + panic("beginDeployment: implemented by the deploy area") +} + +// computeEnv is the node's container environment: its variables with ${{ node.KEY }} references +// expanded (docs/go/spec/projects.md §5.4). Called by: deploy (apply), observability (`keel run`). +// Owner: canvas. +func computeEnv(tx Tx, node domain.Node) (map[string]string, error) { + panic("computeEnv: implemented by the canvas area") +} + +// markReferrersDirty marks every node whose variables reference node (transitively) dirty +// (docs/go/spec/projects.md §5.6). Called by: canvas, observability (tracing switch), migrations. +// Owner: canvas. +func markReferrersDirty(tx Tx, ch *Changes, org string, node domain.Node) error { + panic("markReferrersDirty: implemented by the canvas area") +} + +// withTracing adds the OTEL_* variables to env when the node's tracing switch is on +// (docs/go/spec/observability.md). Called by: deploy (apply). Owner: observability. +func (a *App) withTracing(tx Tx, node domain.Node, env map[string]string) (map[string]string, error) { + panic("withTracing: implemented by the observability area") +} + +// followPort moves every unpinned endpoint of the node to port (docs/go/spec/proxy-ingress.md). +// Returns true when one moved (the caller then calls ScheduleProxySync after commit). +// Called by: deploy (apply). Owner: ingress. +func followPort(tx Tx, ch *Changes, scope NodeScope, port int, now int64) (bool, error) { + panic("followPort: implemented by the ingress area") +} + +// ScheduleProxySync rebuilds and loads keel-proxy's config soon (coalesced). Safe to call often. +// Called by: canvas (node delete), deploy (apply), ingress. Owner: ingress. +func (a *App) ScheduleProxySync() { + panic("ScheduleProxySync: implemented by the ingress area") +} + +// ScheduleObserve observes one node's Swarm service soon, debounced per node (500 ms default, +// docs/go/spec/projects.md §8.6). Called by: canvas (node delete), deploy. Owner: deploy. +func (a *App) ScheduleObserve(nodeID string) { + panic("ScheduleObserve: implemented by the deploy area") +} + +// Recover is the start-up pass that replaces durable scheduling: observe everything, re-arm +// deployment timeouts, proxy sync, data migrations. serve calls it once. Each area adds its part +// in its own file as a function named recover(ctx) and calls it from here. +// Owner: foundation (the list); areas (their parts). +func (a *App) Recover(ctx context.Context) { + for _, fn := range []func(context.Context){a.recoverDeploy, a.recoverIngress, a.recoverObservability} { + fn(ctx) + } +} + +func (a *App) recoverDeploy(ctx context.Context) {} // owner: deploy (replace) +func (a *App) recoverIngress(ctx context.Context) {} // owner: ingress (replace) +func (a *App) recoverObservability(ctx context.Context) {} // owner: observability (replace) From fcb76d8b397171f80742c535d6996cc8c3ce8ffa Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:28:23 +0000 Subject: [PATCH 003/249] Go: endpoint view, command registration hook, remove-service seam Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/api/api.go | 2 +- internal/api/endpoint.go | 28 +++++++++++++++++++++ internal/app/seams.go | 6 +++++ internal/cli/root.go | 7 ++++++ internal/domain/endpoint.go | 50 +++++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/api/endpoint.go diff --git a/internal/api/api.go b/internal/api/api.go index a755ac8..80a9c34 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,6 +1,6 @@ // Package api holds the wire types of Keel's HTTP API: request and response bodies and the error // shape. The server (transport/http) and the CLI (cli) both use them, so a field means the same -// thing on both ends. Stdlib only. JSON names are camelCase; times are unix milliseconds. +// thing on both ends. Imports only stdlib and domain (for small view constructors). JSON names are camelCase; times are unix milliseconds. package api // Problem is every error response: RFC 9457 problem details plus `code`, the stable code callers diff --git a/internal/api/endpoint.go b/internal/api/endpoint.go new file mode 100644 index 0000000..6d9933f --- /dev/null +++ b/internal/api/endpoint.go @@ -0,0 +1,28 @@ +package api + +import "github.com/ThallesP/keel/internal/domain" + +// EndpointView is an endpoint as clients see it (docs/go/spec/proxy-ingress.md §3.7). pinnedPort +// and status.at are not exposed. The dashboard's canvas types mirror it. +type EndpointView struct { + Protocol string `json:"protocol" enum:"http,tcp,udp"` + Port int `json:"port" doc:"Container port the proxy dials"` + Domain string `json:"domain,omitempty"` + PublicPort *int `json:"publicPort,omitempty"` + Address string `json:"address" doc:"https://, or :"` + State string `json:"state" enum:"starting,live,failed"` + Error string `json:"error,omitempty"` +} + +// EndpointViewOf builds the view; publicIP is KEEL_PUBLIC_IP ("" when unknown). +func EndpointViewOf(e domain.Endpoint, publicIP string) EndpointView { + return EndpointView{ + Protocol: string(e.Protocol), + Port: e.Port, + Domain: e.Domain, + PublicPort: e.PublicPort, + Address: e.Address(publicIP), + State: string(e.Status.State), + Error: e.Status.Error, + } +} diff --git a/internal/app/seams.go b/internal/app/seams.go index e3e18ef..2c22620 100644 --- a/internal/app/seams.go +++ b/internal/app/seams.go @@ -72,6 +72,12 @@ func (a *App) ScheduleObserve(nodeID string) { panic("ScheduleObserve: implemented by the deploy area") } +// ScheduleRemoveService removes the node's Swarm service (svc-) soon; a missing service is +// fine. Called by: canvas (node delete). Owner: deploy. +func (a *App) ScheduleRemoveService(nodeID string) { + panic("ScheduleRemoveService: implemented by the deploy area") +} + // Recover is the start-up pass that replaces durable scheduling: observe everything, re-arm // deployment timeouts, proxy sync, data migrations. serve calls it once. Each area adds its part // in its own file as a function named recover(ctx) and calls it from here. diff --git a/internal/cli/root.go b/internal/cli/root.go index 83b5ac5..91f3260 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -39,6 +39,10 @@ func serverCommands() []*cobra.Command { return []*cobra.Command{serveCmd, openapiCmd} } +// Extra registers more top-level commands (keel proxy, keel agent, the CLI verbs). Areas add to it +// from init() in their own file, so root.go never needs editing. +var Extra []func() *cobra.Command + // Root is the keel command. func Root() *cobra.Command { root := &cobra.Command{ @@ -48,6 +52,9 @@ func Root() *cobra.Command { SilenceErrors: true, } root.AddCommand(serverCommands()...) + for _, mk := range Extra { + root.AddCommand(mk()) + } return root } diff --git a/internal/domain/endpoint.go b/internal/domain/endpoint.go index 0ef3fa2..5af24bb 100644 --- a/internal/domain/endpoint.go +++ b/internal/domain/endpoint.go @@ -38,3 +38,53 @@ type Endpoint struct { PublicPort *int `json:"publicPort,omitempty"` // tcp/udp only; unique per protocol Status EndpointStatus `json:"status"` } + +// Key is the endpoint's identity across the install: http:, tcp:, udp:<…>. +func (e Endpoint) Key() string { + if e.Protocol == ProtocolHTTP { + return "http:" + e.Domain + } + port := 0 + if e.PublicPort != nil { + port = *e.PublicPort + } + return string(e.Protocol) + ":" + itoa(port) +} + +// Address is how the endpoint is reached: https://, or : (the literal +// text "" when KEEL_PUBLIC_IP is unknown). docs/go/spec/proxy-ingress.md §3.6. +func (e Endpoint) Address(publicIP string) string { + if e.Protocol == ProtocolHTTP { + return "https://" + e.Domain + } + if publicIP == "" { + publicIP = "" + } + port := 0 + if e.PublicPort != nil { + port = *e.PublicPort + } + return publicIP + ":" + itoa(port) +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + var b [20]byte + n := len(b) + for i > 0 { + n-- + b[n] = byte('0' + i%10) + i /= 10 + } + if neg { + n-- + b[n] = '-' + } + return string(b[n:]) +} From da55f22db9181ebaac25801709e541788c255f23 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:30:13 +0000 Subject: [PATCH 004/249] Go: config for the agent's Swarm service and the data dir Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/app/app.go | 5 +++++ internal/serve/serve.go | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index 2bcf9dd..61e0d7b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -37,6 +37,11 @@ type Config struct { AxiomAuthURL string // KEEL_AXIOM_AUTH_URL override (tests) AxiomAPIURL string // KEEL_AXIOM_API_URL override (tests) AllowLocalSinks bool // KEEL_ALLOW_LOCAL_SINKS + DataDir string // KEEL_DATA_DIR + // The per-node agent as a Swarm global service (replaces scripts/deploy-worker.sh). Empty + // AgentImage = serve does not manage it. + AgentImage string // KEEL_AGENT_IMAGE + AgentControlURL string // KEEL_AGENT_CONTROL_URL: how agents reach serve (tailnet URL) } // New fills defaults: a no-op publisher, the wall clock, slog.Default. diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 0e766fb..80861a8 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -47,6 +47,9 @@ func ConfigFromEnv(version string) app.Config { AxiomAuthURL: Env("KEEL_AXIOM_AUTH_URL", ""), AxiomAPIURL: Env("KEEL_AXIOM_API_URL", ""), AllowLocalSinks: Env("KEEL_ALLOW_LOCAL_SINKS", "") == "1", + DataDir: Env("KEEL_DATA_DIR", "/data"), + AgentImage: Env("KEEL_AGENT_IMAGE", ""), + AgentControlURL: strings.TrimRight(Env("KEEL_AGENT_CONTROL_URL", ""), "/"), } } @@ -56,7 +59,7 @@ func Run(ctx context.Context, opts Options) error { slog.SetDefault(log) cfg := ConfigFromEnv(opts.Version) - dataDir := Env("KEEL_DATA_DIR", "/data") + dataDir := cfg.DataDir if err := os.MkdirAll(dataDir, 0o700); err != nil { return err } From b1f385c6d56fd4f721c900e931ca3bcd1f2e6d0a Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:42:36 +0000 Subject: [PATCH 005/249] Go: porting specs for every subsystem, resolved API decisions Seven subsystem specs (auth, canvas/deploy, swarm/worker, observability, ingress, web data layer, CLI/install) written from the TypeScript, plus a completeness pass and an index. ARCHITECTURE.md settles the routes, null envelopes, error codes and env names where the specs disagreed. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/go/ARCHITECTURE.md | 25 +- docs/go/spec/INDEX.md | 195 +++++ docs/go/spec/auth-orgs.md | 996 +++++++++++++++++++++++++ docs/go/spec/cli-install.md | 1145 +++++++++++++++++++++++++++++ docs/go/spec/observability.md | 1024 ++++++++++++++++++++++++++ docs/go/spec/projects.md | 981 +++++++++++++++++++++++++ docs/go/spec/proxy-ingress.md | 1278 +++++++++++++++++++++++++++++++++ docs/go/spec/swarm-worker.md | 1097 ++++++++++++++++++++++++++++ docs/go/spec/web-data.md | 944 ++++++++++++++++++++++++ internal/serve/serve.go | 2 +- 10 files changed, 7684 insertions(+), 3 deletions(-) create mode 100644 docs/go/spec/INDEX.md create mode 100644 docs/go/spec/auth-orgs.md create mode 100644 docs/go/spec/cli-install.md create mode 100644 docs/go/spec/observability.md create mode 100644 docs/go/spec/projects.md create mode 100644 docs/go/spec/proxy-ingress.md create mode 100644 docs/go/spec/swarm-worker.md create mode 100644 docs/go/spec/web-data.md diff --git a/docs/go/ARCHITECTURE.md b/docs/go/ARCHITECTURE.md index 6896fe9..8e7f343 100644 --- a/docs/go/ARCHITECTURE.md +++ b/docs/go/ARCHITECTURE.md @@ -141,11 +141,11 @@ HTTP status and writes RFC 9457 `application/problem+json` with the extra field | ------------------------------------- | ------------------------------------------------------------------------------ | | `KEEL_LISTEN` (`:8080`) | HTTP listen address (compose publishes it on the tailnet IP) | | `KEEL_DATA_DIR` (`/data`) | SQLite file and other state | -| `KEEL_SITE_URL` | Dashboard URL as users open it (device-login links, Secure cookies on https) | +| `KEEL_SITE_URL` (fallback `SITE_URL`) | Dashboard URL as users open it (device-login links, Secure cookies, OTLP/report base) | | `KEEL_WORKER_TOKEN` | Bearer for agent and proxy routes | | `KEEL_PUBLIC_IP`, `KEEL_ACME_CA`, `KEEL_ACME_EMAIL` | Ingress, as before (docs/networking.md) | | `KEEL_OTLP_URL` | OTLP relay URL injected into traced services | -| `KEEL_PROXY_ADMIN` (`/run/keel-proxy/admin.sock`) | keel-proxy admin socket | +| `KEEL_PROXY_SOCKET` (`/run/keel-proxy/admin.sock`) | keel-proxy admin socket (the edge stays its own container) | | `DOCKER_HOST` | Docker socket (default `unix:///var/run/docker.sock`) | | `KEEL_AXIOM_AUTH_URL`, `KEEL_AXIOM_API_URL`, `KEEL_ALLOW_LOCAL_SINKS` | Axiom overrides for tests, as before | @@ -185,3 +185,24 @@ merged. Rules that keep the merge mechanical: committed on your branch. Toolchain: `export PATH=$HOME/.local/go/bin:$HOME/go/bin:$PATH` (Go 1.27 via GOTOOLCHAIN=auto). + +## Resolved API decisions + +Where the specs disagree (critic addenda W1, C1, C2), this table wins. + +| Topic | Decision | +| --- | --- | +| Error body | `application/problem+json` with `code` from the CLI vocabulary (`domain.Code*`); `INVALID_INPUT` is 422. Never lowercase codes. The CLI uses `code` as is and computes only `fix`. `CONFLICT` and `UNAVAILABLE` join the CLI's code list (codes are only added). | +| Null reads | A read that returned `null` in Convex (missing, foreign, malformed id, signed out where the Convex query returned null) returns **200** with a named envelope field set to `null`: `{summary}`, `{deployment}`, `{tracing}`, `{project}`, `{invitation}`. Never 404 for those. Writes on missing things fail with the spec's error. | +| Session | `GET /api/me` → `{user: {id,email,name} \| null, organization: {id,name,slug,role} \| null}`, 200 even when signed out. It backs the dashboard's `useSession()` and `keel whoami`. Topic `/api/me` (membership changes). | +| Accounts | `GET /api/auth/sign-up-open` → `{open}`; `POST /api/auth/sign-up {email,password,name,invitationId?}`; `POST /api/auth/sign-in {email,password}`; `POST /api/auth/sign-out`. Sign-up and sign-in set the cookie and also return `{token}` (CI and the CLI use it). `ci.yml` moves to these paths in this PR. | +| Device login | Paths stay under `/api/auth/device`: `POST .../code {client_id}` → RFC 8628 body; `POST .../token {grant_type, device_code, client_id}` → `{access_token, token_type, expires_in}` or 400 `{error: authorization_pending\|slow_down\|expired_token\|access_denied, error_description}` (RFC shape, not a problem); `GET /api/auth/device?user_code=` (signed in; shows and binds the code); `POST .../approve {userCode}`; `POST .../deny {userCode}`. | +| Organization | `GET /api/organization/members`; `GET/POST /api/organization/invitations` (`POST {email, role?}` → `{id,email,role,expiresAt}`); `DELETE /api/organization/invitations/{id}`; public `GET /api/invitations/{id}` → `{invitation: {email, organization} \| null}`. | +| Projects | `GET /api/projects`; `POST /api/projects {name}`; `POST /api/projects/default` → `{slug}`; `GET /api/projects/by-slug/{slug}` → `{project \| null}`. | +| Canvas | `GET /api/environments/{id}/nodes` → `{nodes: NodeView[]}`; `GET /api/environments/{id}/summary` → `{summary \| null}`; `POST /api/environments/{id}/nodes`; `PATCH /api/nodes/{id}` (rename, desired, config, parent); `PUT /api/nodes/{id}/position {x,y}` (one per node); `POST /api/nodes/{id}/duplicate`, `/start`, `/stop`; `DELETE /api/nodes/{id}`. | +| Variables | Key in the body, never in the path: `GET /api/nodes/{id}/variables`; `POST /api/nodes/{id}/variables {key, value, secret, previousKey?}`; `POST /api/nodes/{id}/variables/delete {key}` (no-op when missing, as Convex). The key is validated in the handler so a bad key gets `Key: UPPER_SNAKE_CASE only`. | +| Deployments | `POST /api/environments/{id}/deployments {only?, refresh?}` (Ship / redeploy / retry) → `{id}`; `GET /api/environments/{id}/deployments/latest` → `{deployment \| null}`; `GET /api/deployments/{id}` → `{deployment \| null}`; `GET /api/nodes/{id}/deployments`. JSON uses `id`, never `_id`. | +| Ingress | `POST /api/nodes/{id}/expose {protocol?, domain?, port?, publicPort?}` → `EndpointView`; `POST /api/nodes/{id}/unexpose {protocol?, domain?, publicPort?}` (no selector = all); `GET /api/control-plane` → `{publicIp: string \| null}` (signed in). | +| Traces | One route: `GET /api/environments/{id}/traces?range=&search=&nodeId=`. | +| Env names | `KEEL_SITE_URL` (fallback `SITE_URL`); `BETTER_AUTH_SECRET` is gone (cookies are opaque random tokens, not signed); `KEEL_LISTEN` `:8080`; `KEEL_DATA_DIR` `/data`; `KEEL_PROXY_SOCKET` and `KEEL_PROXY_REPORT_URL` stay (the edge is a separate container, proxy-ingress.md §12.4 option 1); `DOCKER_HOST`. Agent env names are unchanged. | +| IDs on import | Imported Convex ids are kept verbatim (they appear in `svc-`, labels, sslip hashes, OTel attributes). New rows get `domain.NewID()`. | diff --git a/docs/go/spec/INDEX.md b/docs/go/spec/INDEX.md new file mode 100644 index 0000000..f7b9fce --- /dev/null +++ b/docs/go/spec/INDEX.md @@ -0,0 +1,195 @@ +# Porting spec index + +Where each piece of today's backend (`packages/backend/convex`, Better Auth, `apps/worker`, +`apps/proxy`) is specified for the Go rewrite. "Primary" is the spec that owns the full contract +(args, return shape, access rule, validation order, exact errors, side effects); "Also" lists +specs that describe it from their angle (callers, realtime keys, Go route, CLI mapping). + +| Spec | Scope | +| --- | --- | +| [auth-orgs.md](auth-orgs.md) | Better Auth tables and endpoints, sessions, sign-up/founding/joining, invitations, device login, `access.ts`, HTTP router | +| [projects.md](projects.md) | projects, environments, canvas nodes, variables and references, dirty/Ship, deployments, reconcile, Swarm spec | +| [swarm-worker.md](swarm-worker.md) | Swarm reconciler (apply/observe), Docker events, deployments settle/timeout, the per-node agent, crons | +| [observability.md](observability.md) | log sinks, Sign in with Axiom, `logs.*`, `traces.*`, `tracing.*`, OTLP relay, agent log shipping | +| [proxy-ingress.md](proxy-ingress.md) | endpoints (expose/unexpose), keel-proxy sync, Caddy config, cert reports, `apps/proxy` | +| [web-data.md](web-data.md) | every web call site, JSON shapes the web reads, optimistic updates, realtime topics (canonical, §10) | +| [cli-install.md](cli-install.md) | `keel` CLI contract and the Convex calls it makes, `install.sh`, compose, CI, Go-binary layout | + +Cross-spec conflicts found by the completeness pass are written up in the addenda: routes, +response envelopes and realtime key names in **web-data.md "Addendum (critic)" W1–W2**; server +error codes and env var names in **cli-install.md "Addendum (critic)" C1–C2**; operational log +lines in **swarm-worker.md "Addendum (critic)" A1**. Resolve W1/C1/C2 before writing handlers. + +Legend: P = public (callable by clients), I = internal. Kinds: Q query (reactive in the web +unless noted), M mutation, A action (never reactive), IQ/IM/IA internal query/mutation/action, +H HTTP action, C cron, S helper scheduled by `ctx.scheduler`. + +## 1. Convex functions → spec + +### 1.1 Auth, organizations, leftovers + +| Function | Kind | Primary | Also | +| --- | --- | --- | --- | +| `auth.getCurrentUser` | P Q | auth-orgs §7.7 | web-data §4.1 Q2/Q22/Q24, §5.6; cli-install A11 | +| `auth.signUpOpen` | P Q (public) | auth-orgs §6.2 | web-data §4.1 Q1 | +| `organizations.current` | P Q | auth-orgs §7.5 | web-data §4.1 Q3/Q5; cli-install A11; observability §4.5 (membership gate) | +| `organizations.invitation` | P Q (public) | auth-orgs §7.6 | web-data §4.1 Q25 | +| `privateData.get` | P Q (template leftover, do not port) | auth-orgs §7.8 | — | +| `healthCheck.get` | P Q (template leftover) | auth-orgs §7.8 | — | +| Better Auth endpoints `/api/auth/*` (sign-up, sign-in, sign-out, get-session, convex/token, convex/jwks, organization/invite-member, organization/accept-invitation, device/code, device/token, device, device/approve, device/deny) and database hooks | H (component routes) | auth-orgs §5, §6.1, §7.2–7.4 | web-data §4.4; cli-install A8 | + +### 1.2 Projects, environments, nodes, variables, deployments + +| Function | Kind | Primary | Also | +| --- | --- | --- | --- | +| `projects.ensureDefault` | P M | projects §9.1 | auth-orgs §6.3 (founding, legacy adoption); web-data §4.2 M1 | +| `projects.create` | P M | projects §9.1 | auth-orgs §6.3; web-data M2; cli-install A11 | +| `projects.getBySlug` | P Q | projects §9.1 | web-data Q23 | +| `projects.list` | P Q | projects §9.1 | auth-orgs §6.3 (no-membership rule); web-data Q21; cli-install A11 | +| `environments.summary` | P Q | projects §9.2 | swarm-worker §17; web-data Q12; cli-install A11 | +| `environments.setServers` | IM | projects §9.2 | swarm-worker §8.6 | +| `nodes.list` | P Q | projects §9.3, §4.3 (`NodeView`) | web-data Q9–Q11, §5.1; proxy-ingress §3.7 (`endpointView`); cli-install A11 | +| `nodes.create` | P M | projects §9.3 | web-data M3; cli-install A11 | +| `nodes.move` | P M | projects §9.3 | web-data M10, §7 (optimistic) | +| `nodes.rename` | P M | projects §9.3, §5.5 | web-data M4 | +| `nodes.setDesired` | P M (no caller today) | projects §9.3 | — | +| `nodes.stop` | P M | projects §9.3 | web-data M9 | +| `nodes.start` | P M | projects §9.3 | web-data M8 | +| `nodes.expose` | P M | proxy-ingress §4.1 | projects §9.3; web-data M12 | +| `nodes.unexpose` | P M | proxy-ingress §4.2 | projects §9.3; web-data M13 | +| `nodes.publicAddress` | P Q (static) | proxy-ingress §4.3 | projects §9.3; web-data Q16 | +| `nodes.duplicate` | P M | projects §9.3 | proxy-ingress §4.4; web-data M5 | +| `nodes.remove` | P M | projects §10 | proxy-ingress §4.4; swarm-worker §7; web-data M11, §7; cli-install A11 | +| `variables.list` | P Q | projects §9.4, §5 | web-data Q18, §5.4; cli-install A11 | +| `variables.referenceable` | P Q | projects §9.4 | web-data Q19 | +| `variables.set` | P M | projects §9.4 | web-data M14; cli-install A11 | +| `variables.remove` | P M | projects §9.4 | web-data M15; cli-install A11 | +| `deployments.start` | P M (`beginDeployment`) | projects §9.5, §7.1 | swarm-worker §10.1; web-data M6/M7; cli-install A11 | +| `deployments.latest` | P Q | projects §9.5 | swarm-worker §17; web-data Q13, §5.3; cli-install A11 | +| `deployments.get` | P Q | projects §9.5 | web-data Q14; cli-install A10.2, A11 | +| `deployments.listForNode` | P Q | projects §9.5 | web-data Q15; cli-install A11 | +| `deployments.stepRunning` | IM | swarm-worker §6.3 | projects §7.2 | +| `deployments.stepLog` | IM | swarm-worker §6.3 | projects §7.2 | +| `deployments.stepApplied` | IM | swarm-worker §6.3 | projects §7.2 | +| `deployments.stepFailed` | IM | swarm-worker §6.3 | projects §7.2 | +| `reconcile.run` | IM | swarm-worker §10.2 | projects §7.4 | +| `reconcile.timeoutDeployment` | IM (scheduled 5 min after each deployment) | swarm-worker §10.3 | projects §7.5 | +| `migrations.run` | IM (every install/upgrade) | projects §9.9 | proxy-ingress §5.7; swarm-worker §16.1 | + +### 1.3 Swarm reconciler, Docker events, worker + +| Function | Kind | Primary | Also | +| --- | --- | --- | --- | +| `swarm.apply` | IA | swarm-worker §6.1 | projects §7.2, §8 | +| `swarm.remove` | IA | swarm-worker §6.6 | projects §9.8, §10 | +| `swarm.removeLegacyTunnels` | IA | proxy-ingress §5.8 | swarm-worker §6.7 | +| `swarm.observeNode` | IA | swarm-worker §8.3 | projects §8.5 | +| `swarm.observeSwarmNodes` | IA | swarm-worker §8.6 | projects §8.5 | +| `swarm.observe` | IA (full sweep) | swarm-worker §8.5 | projects §8.5 | +| `events.ingest` | IM | swarm-worker §8.1 | projects §8.7 | +| `nodesInternal.owned` | IQ (dead code) | projects §9.7 | swarm-worker §20 | +| `nodesInternal.listDeployable` | IQ | projects §9.7 | swarm-worker §8.5 | +| `nodesInternal.applyInput` | IQ | swarm-worker §6.1 | projects §7.2; observability §8.2 (`withTracing`) | +| `nodesInternal.setObserved` | IM | swarm-worker §8.7 | projects §7.3 | +| `nodesInternal.scheduleObserve` | IM (debounce) | swarm-worker §8.2 | projects §8.6 | +| `nodesInternal.clearObserveScheduled` | IM | swarm-worker §8.2 | projects §9.7 | +| `nodesInternal.setApplyError` | IM | swarm-worker §6.4 | projects §9.7 | +| `nodesInternal.followPort` | IM | proxy-ingress §5.6 | swarm-worker §6.5; projects §7.2 | +| `worker.config` | IQ (served as `GET /worker/config`) | observability §11.1 | swarm-worker §12 | + +### 1.4 Log sinks, logs, traces, tracing, OTLP relay + +| Function | Kind | Primary | Also | +| --- | --- | --- | --- | +| `logSinks.get` | P Q | observability §4.1 | web-data Q4/Q6, §5.7 | +| `logSinks.forNode` | IQ | observability §4.2 | auth-orgs §9.3 | +| `logSinks.forEnvironment` | IQ | observability §4.3 | auth-orgs §9.3 | +| `logSinks.save` | IM | observability §4.4 | — | +| `logSinks.connectAxiom` | P A (no UI caller) | observability §4.5 | auth-orgs §9.3 | +| `logSinks.disconnect` | P M | observability §4.6 | web-data M16 | +| `logSinks.beginAxiomSignIn` | P A | observability §4.7 | web-data A3; auth-orgs §9.3, §13 (defect 4) | +| `logSinks.clientFor` | IQ | observability §4.7 (step 2) | — | +| `logSinks.saveClient` | IM | observability §4.7 | — | +| `logSinks.startSignIn` | IM | observability §4.7 (step 5) | — | +| `logSinks.signInAxiom` | P A | observability §4.7 | web-data A10 | +| `logSinks.takeSignIn` | IM | observability §4.7 | — | +| `logSinks.stashPending` | IM | observability §4.7 | — | +| `logSinks.chooseAxiomOrg` | P A | observability §4.7 | web-data A4 | +| `logSinks.takePending` | IM | observability §4.7 | — | +| `logSinks.pendingOrgs` | P Q | observability §4.7 | web-data Q7/Q8 | +| `logSinks.cancelAxiomSignIn` | P M | observability §4.7 | web-data M17 | +| `logSinks.dropSignIn` | IM (S, +10 min) | observability §4.7 "Scheduled expiries" | — | +| `logSinks.dropPending` | IM (S, +10 min) | observability §4.7 "Scheduled expiries" | — | +| `logs.tail` | P A (polled 3 s) | observability §5.1 | web-data A1; cli-install A10.4, A11 | +| `logs.recent` | P A (polled 10 s) | observability §5.2 | web-data A5 | +| `logs.around` | P A | observability §5.3 | web-data A7 | +| `traces.overview` | P A (polled 10 s) | observability §6.3 | web-data A6; cli-install A10.5, A11 | +| `traces.get` | P A | observability §6.4 | web-data A9 | +| `traces.around` | P A | observability §6.5 | web-data A8 | +| `tracing.scope` | IQ | observability §8.3 | auth-orgs §9.3 | +| `tracing.setEnabled` | IM | observability §8.4 | projects §9.10 | +| `tracing.enable` | P A | observability §8.5 | web-data A2; cli-install A11 | +| `tracing.forNode` | P Q | observability §8.6 | web-data Q17, §5.5; cli-install A11 | +| `tracing.localEnv` | P A | observability §8.7 | cli-install A10.3 (`keel run`), A11 | +| `tracing.prompt` | P Q (`meta.realtime = false` in Go) | observability §8.8, §10 (text) | web-data Q20; cli-install A11 | +| `otlp.keyOf` | IQ | observability §9.1 | — | +| `otlp.saveKey` | IM | observability §9.1 | web-data §10.3 | +| `otlp.route` | IQ | observability §9.1 | — | +| `otlp.traces` | H (`POST /otlp/v1/traces`) | observability §9.2 | auth-orgs §10 | + +### 1.5 keel-proxy + +| Function | Kind | Primary | Also | +| --- | --- | --- | --- | +| `proxy.sync` | IA (Node runtime) | proxy-ingress §5.1, §6 (Caddy config) | swarm-worker §7 | +| `proxyInternal.syncInput` | IQ | proxy-ingress §5.2 | — | +| `proxyInternal.setStatuses` | IM | proxy-ingress §5.3 | — | +| `proxyInternal.resync` | IM (cron) | proxy-ingress §5.4 | swarm-worker §16.2 | +| `proxyInternal.certReport` | IM | proxy-ingress §5.5 | auth-orgs §10 | + +### 1.6 HTTP routes, cron, scheduled jobs + +| Entry point | Kind | Primary | Also | +| --- | --- | --- | --- | +| `POST /worker/events` → `events.ingest` | H, bearer `KEEL_WORKER_TOKEN` | swarm-worker §11.2 | auth-orgs §10; projects §8.7 | +| `GET /worker/config` → `worker.config` | H, bearer | swarm-worker §11.3 | observability §11.1; auth-orgs §10 | +| `POST /proxy/events` → `proxyInternal.certReport` | H, bearer | proxy-ingress §5.9 | auth-orgs §10; swarm-worker §11.4 | +| `POST /otlp/v1/traces` (`otlp.traces`) | H, environment ingest key | observability §9.2 | auth-orgs §10 | +| `GET /.well-known/openid-configuration` (redirect) | H (component) | auth-orgs §10 | dropped in Go | +| cron `keel-proxy resync` (every 2 min → `proxyInternal.resync`) | C | proxy-ingress §5.4 | swarm-worker §16.2 | +| `scheduler.runAfter` targets: `swarm.apply`, `reconcile.timeoutDeployment` (+5 min) from `beginDeployment`; `swarm.remove`, `reconcile.run`, `proxy.sync` from `nodes.remove`; `proxy.sync` from expose/unexpose/followPort/resync/migrations; `swarm.removeLegacyTunnels` from migrations; `swarm.observeNode` (debounced 500 ms / settle +2 s) from `scheduleObserveFor`; `swarm.observe` and `swarm.observeSwarmNodes` from `events.ingest`; `logSinks.dropSignIn` / `dropPending` (+10 min) | S | swarm-worker §7 (call graph), §18 (durability) | projects §0, §7; proxy-ingress §5.1; observability §4.7 | +| Pure helpers: `access.ts`, `status.ts`, `nodeHelpers.ts`, `endpoints.ts`, `timeRange.ts`, `tracingPrompt.ts`, `variables.ts` (`computeEnv`, `markReferrersDirty`, `renameReferences`, `serviceHost`), `tracing.ts` (`tracingEnv`, `withTracing`), `logProviders/*`, `traceProviders/*`, `otlp.ts` (`ensureKey`) | — | auth-orgs §9 / projects §2–§5 / swarm-worker §9 / proxy-ingress §3 / observability §3, §5–§8, §10 | — | + +## 2. Tables → spec + +| Table | Store | Primary | Also | Indexes | +| --- | --- | --- | --- | --- | +| `projects` | app | projects §1.1 | auth-orgs §3.2 (`organizationId`, legacy `ownerId`) | `by_organization [organizationId]`, `by_slug [organizationId, slug]` | +| `environments` | app | projects §1.2 | — | `by_project [projectId]` | +| `nodes` | app | projects §1.3 | swarm-worker §2.1; proxy-ingress §2.1–2.3 (`endpoints`, legacy `public`/`ingress`); observability §1.7 (`desired.tracing`) | `by_environment [environmentId]` | +| `variables` | app | projects §1.4 | proxy-ingress §2.4 | `by_node [nodeId]` | +| `deployments` | app | projects §1.5 | swarm-worker §2.2 | `by_environment [environmentId]`, `by_status [status]` | +| `cluster` | app (single row) | projects §1.6 | swarm-worker §2.3 | — | +| `logSinks` | app | observability §1.1, §1.8 (legacy rows) | auth-orgs §3.2 | `by_organization [organizationId]` | +| `axiomClients` | app | observability §1.2 | — | `by_redirect [redirectUri]` | +| `axiomSignIns` | app | observability §1.3 | auth-orgs §3.2 | `by_state [state]`, `by_organization [organizationId]` | +| `axiomPending` | app | observability §1.4 | auth-orgs §3.2 | `by_organization [organizationId]` | +| `otlpKeys` | app | observability §1.5 | — | `by_key [key]`, `by_environment [environmentId]` | +| `user` | Better Auth component | auth-orgs §3.1 | — | `email_name`, `name`, `userId` | +| `session` | Better Auth component | auth-orgs §3.1 | — | `expiresAt`, `expiresAt_userId`, `token`, `userId` | +| `account` | Better Auth component | auth-orgs §3.1 | — | `accountId`, `accountId_providerId`, `providerId_userId`, `userId` | +| `verification` | Better Auth component (not needed in Go) | auth-orgs §3.1 | — | `expiresAt`, `identifier` | +| `organization` | Better Auth component | auth-orgs §3.1 | observability §1.7 (slug → dataset names) | `name`, `slug` | +| `member` | Better Auth component | auth-orgs §3.1 | — | `organizationId`, `userId`, `role`, `organizationId_userId` | +| `invitation` | Better Auth component | auth-orgs §3.1 | — | `organizationId`, `email`, `role`, `status`, `inviterId`, `organizationId_status`, `email_organizationId_status` | +| `deviceCode` | Better Auth component | auth-orgs §3.1 | cli-install A8 | `deviceCode`, `userCode` | +| `jwks` | Better Auth component (not needed in Go) | auth-orgs §3.1 | — | — | +| `_scheduled_functions` | Convex system (via `nodes.observeScheduled`) | swarm-worker §8.2, §18 | projects §1.3 (do not port as a column) | — | + +Embedded object types (schema validators): `desired`, `observed` → projects §1.3; `endpoint`, +`endpointStatus` → proxy-ingress §2.1; `position`, `config` → projects §1.3, §3.5; `deployStep`, +`logLine` → projects §1.5; `logSink`, `axiomOrg` → observability §1.6; `dockerEvent` → +swarm-worker §8.1; `engine` → projects §3.2; `timeRange` → observability §3.1. + +Per-node agent state (not a table): `state.json` `{eventsSince?, logsSince}` → swarm-worker §13.7, +observability §11.3. diff --git a/docs/go/spec/auth-orgs.md b/docs/go/spec/auth-orgs.md new file mode 100644 index 0000000..a0a8899 --- /dev/null +++ b/docs/go/spec/auth-orgs.md @@ -0,0 +1,996 @@ +# Porting spec: authentication, organizations, access control, HTTP router + +Scope: everything a Go rewrite needs to reproduce how Keel authenticates people, the CLI, the +per-node worker and keel-proxy; how the one organization of an install is founded and joined; which +access rule every function applies; and every HTTP route the Convex deployment serves. + +Sources read (worktree `go`, commit `36c2ded`): +`packages/backend/convex/{auth.ts, auth.config.ts, convex.config.ts, access.ts, organizations.ts, +projects.ts (founding), privateData.ts, healthCheck.ts, http.ts, otlp.ts (route only), schema.ts}`, +`packages/backend/convex/betterAuth/*`, the installed libraries `better-auth@1.6.17`, +`@convex-dev/better-auth@0.12.5`, `convex-helpers@0.1.124` (cors), `@better-auth/utils@0.4.1` +(password hashing), web `apps/web/src/{lib/auth-client.ts, lib/config.ts, main.tsx, +components/{auth-forms,sign-in-form,sign-up-form,invite-dialog}.tsx, components/canvas/account-menu.tsx, +routes/{index,invite.$invitationId}.tsx, routes/_auth/{route,device}.tsx}`, CLI +`apps/cli/internal/{keel/{auth,api,types}.go, convex/convex.go, cli/{auth,context}.go, config/config.go, +output/output.go}` and tests, `.github/workflows/ci.yml` (the auth smoke test), `install.sh`, +`deploy/functions-entrypoint.sh`, `apps/web/docker-entrypoint.sh`, `apps/worker/src/controlPlane.ts`, +`apps/proxy/keel.go`. + +Conventions in this file: + +- "Current" = what the TypeScript/Convex system does today. "Go" = a recommendation for the port, + aligned with `docs/go/ARCHITECTURE.md`. Where they differ the spec says so explicitly. +- Strings in `code font` inside quotes are exact and load-bearing (UI shows them; the CLI matches + some of them). Do not reword. +- Times are unix milliseconds unless stated. + +--- + +## 1. Model in one page + +- **One organization per install.** It is never created by a user through Better Auth + (`allowUserToCreateOrganization: false`). The first signed-in account founds it lazily + (`projects.ensureDefault` or `projects.create` → `joinOrFound`). Name `"Default"`, slug `"default"`. + The founder's role is `"owner"`. +- **Sign-up is open only until the first account exists.** Every later sign-up must carry a + pending, unexpired invitation id whose email equals the sign-up email (case-insensitive, trimmed). + The new account joins the invitation's organization with the invitation's role (UI always sends + `"member"`), and the invitation flips to `"accepted"`. +- **Every member has the same access to Keel resources.** No Keel function looks at the role. The + role only matters to Better Auth's own organization endpoints (only `owner`/`admin` may create + invitations). Access rule for every resource: "the resource's project belongs to the caller's + organization". +- **A user has at most one membership** (assumed everywhere; lookups use "first member row with + `userId`"). +- **Sessions** are Better Auth sessions (opaque 32-char token, 7-day expiry, rolling refresh). + Browsers keep the session cookie in localStorage (cross-domain plugin) and get a 15-minute RS256 + JWT that the Convex client presents to Convex; Convex functions resolve the user from that JWT + plus a live session row. The CLI holds the raw session token, swaps it for a JWT each run, and + calls Convex's HTTP API. +- **CLI login** is RFC 8628 device authorization (`client_id` `keel-cli`); a signed-in member + approves on the dashboard's `/device` page. +- **Machine routes** (`/worker/*`, `/proxy/events`) use one shared bearer `KEEL_WORKER_TOKEN` + compared in constant time. `/otlp/v1/traces` uses per-environment ingest keys. + +--- + +## 2. Environment variables (this area) + +| Name | Read by | Meaning | Default / required | +| --- | --- | --- | --- | +| `SITE_URL` | `auth.ts` | Dashboard origin as users open it, e.g. `http://100.64.0.1` (port appended only when not 80). It is Better Auth's only trusted origin (`trustedOrigins: [SITE_URL]`), the CORS allow-list of `/api/auth/*`, the device `verificationUri` base (`${SITE_URL}/device`), and the cross-domain plugin's `siteUrl` (relative callback URLs are resolved against it). | Required. `install.sh` sets `SITE_URL="http://$KEEL_ADDR"` (+`:$KEEL_WEB_PORT` when ≠80); `functions-entrypoint.sh deploy` refuses to run without it. | +| `CONVEX_SITE_URL` | `auth.ts` (`baseURL`), convex plugin, auth config | Convex HTTP-actions origin (`http://$KEEL_ADDR:3211`). Better Auth `baseURL` (so auth lives at `${CONVEX_SITE_URL}/api/auth/*`), JWT `iss`, JWKS URL. Built into Convex from `CONVEX_SITE_ORIGIN`. | Provided by Convex. | +| `BETTER_AUTH_SECRET` | Better Auth (implicit) | HMAC key for signed session cookies (`.`), the bearer plugin's signature check, encryption of JWKS private keys. | Required. `install.sh`: 32 random bytes as 64 hex chars, persisted and reused on upgrade. | +| `KEEL_WORKER_TOKEN` | `http.ts` | Bearer for `POST /worker/events`, `GET /worker/config`, `POST /proxy/events`. Unset → every one of those requests is 401. | Required (install: 64 hex chars). | +| `NODE_ENV` | Better Auth | `production` enables the built-in in-memory rate limiter (see §4.6). | Convex runtime decides. | + +Not in this area but on the same router: `KEEL_PUBLIC_IP`, `KEEL_ACME_EMAIL`, `KEEL_OTLP_URL`, etc. + +Go (`docs/go/ARCHITECTURE.md`): `KEEL_SITE_URL` replaces `SITE_URL`; `BETTER_AUTH_SECRET` is only +needed if the port keeps signed cookies or must verify old signed tokens (it does not need to: the +stored session token is enough, see §13). `CONVEX_SITE_URL` disappears (same origin). + +--- + +## 3. Data + +### 3.1 Better Auth component tables (`convex/betterAuth/generatedSchema.ts` + `schema.ts`) + +These live in the Convex component `betterAuth` (local install), not in the app schema. App code +reads them through `components.betterAuth.adapter.{findOne,findMany,create,...}` with `where` +clauses; the adapter maps the field `id` to Convex `_id`. Every row also has Convex system fields +`_id` (string id, the Better Auth `id`) and `_creationTime` (ms float). Dates are stored as ms +numbers. "opt" = `v.optional(v.union(v.null(), T))` (absent or null). + +#### `user` + +| Field | Type | Meaning | +| --- | --- | --- | +| `name` | string | Display name (sign-up form requires ≥2 chars client-side; server accepts any string). | +| `email` | string | Lower-cased by Better Auth on sign-up (`email.toLowerCase()`); lookups lower-case too. | +| `emailVerified` | bool | Always `false` in Keel (no verification flow). Matters: see §7.4 (accept-invitation quirk). | +| `image` | opt string | Unused. | +| `createdAt`, `updatedAt` | number | ms. | +| `userId` | opt string | Added by the convex plugin schema (legacy app-user link). Unused. | + +Indexes: `email_name [email,name]`, `name [name]`, `userId [userId]`. + +#### `session` + +| Field | Type | Meaning | +| --- | --- | --- | +| `token` | string | The session token: `generateId(32)`, 32 chars `[a-zA-Z0-9]`. What the CLI stores and sends as `Bearer`; cookie value is `token + "." + base64url(HMAC-SHA256(BETTER_AUTH_SECRET, token))`. | +| `expiresAt` | number | ms. Created at now+7d (now+1d if `rememberMe:false`, never sent by Keel). | +| `createdAt`, `updatedAt` | number | ms. | +| `ipAddress`, `userAgent` | opt string | Request metadata at creation. | +| `userId` | string | `user._id`. | +| `activeOrganizationId` | opt string | Set by Keel's `session.create.before` hook to the user's membership org id, else null (§6.1). Read only by Better Auth's org endpoints as a default; Keel code never reads it. Not updated when the founder founds the org afterwards. | + +Indexes: `expiresAt`, `expiresAt_userId`, `token`, `userId`. + +#### `account` + +One row per credential. Keel only has `providerId: "credential"`, `accountId = user id`, +`password` = hash (§4.5). Other columns (`accessToken`, `refreshToken`, `idToken`, +`accessTokenExpiresAt`, `refreshTokenExpiresAt`, `scope`) are unused (OAuth). `userId`, +`createdAt`, `updatedAt`. Indexes: `accountId`, `accountId_providerId`, `providerId_userId`, `userId`. + +#### `verification` + +`identifier` string, `value` string, `expiresAt`, `createdAt`, `updatedAt`. Used only by the +cross-domain one-time-token flow (OAuth redirects, not used by Keel) and Better Auth internals. +Indexes: `expiresAt`, `identifier`. Not needed in Go. + +#### `organization` + +| Field | Type | Meaning | +| --- | --- | --- | +| `name` | string | `"Default"` (only value ever written; nothing renames it). | +| `slug` | string | `"default"`. Used by logSinks to name Axiom datasets (`keel-`). | +| `logo`, `metadata` | opt string | Unused. | +| `createdAt` | number | ms. | + +Indexes: `name`, `slug`. Exactly zero or one row per install. + +#### `member` + +| Field | Type | Meaning | +| --- | --- | --- | +| `organizationId` | string | `organization._id`. | +| `userId` | string | `user._id`. | +| `role` | string | `"owner"` (founder) or the invitation's role (`"member"` from the UI). Better Auth also knows `"admin"`; a comma list is legal in Better Auth but never produced. | +| `createdAt` | number | ms. | + +Indexes: `organizationId`, `userId`, `role`, `organizationId_userId [organizationId,userId]`. +Invariant (not enforced by a unique index): at most one row per user. + +#### `invitation` + +| Field | Type | Meaning | +| --- | --- | --- | +| `organizationId` | string | Target org. | +| `email` | string | Lower-cased by `invite-member`. | +| `role` | opt string | Role to grant; `"member"` from the UI. Null → `"member"` in Keel's sign-up hook. | +| `status` | string | `"pending"` → `"accepted"` \| `"canceled"` (re-invite) \| `"rejected"` (reject endpoint, unused). Never deleted. | +| `expiresAt` | number | createdAt + 7 days (`INVITATION_TTL_S = 604800`). | +| `createdAt` | number | ms. | +| `inviterId` | string | `user._id` of the inviter. | + +Indexes: `organizationId`, `email`, `role`, `status`, `inviterId`, `organizationId_status`, +`email_organizationId_status`. **The invitation `_id` is the secret in the invite link** +(`/invite/`). Convex ids are not guessable in practice; the Go port must use ≥128 +bits of randomness for invitation ids (the architecture's 20-char base32 `NewID` is 100 bits: use a +longer id or a separate random token for invitations). + +#### `deviceCode` + +| Field | Type | Meaning | +| --- | --- | --- | +| `deviceCode` | string | 40 chars `[a-zA-Z0-9]`. Secret held by the CLI. | +| `userCode` | string | 8 chars from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (`byte % 32` of random bytes). Shown as `ABCD-EFGH`. | +| `userId` | opt string | Null until a signed-in user opens `GET /device?user_code=…` (binding). Only that user may approve/deny. | +| `expiresAt` | number | now + 30 min. | +| `status` | string | `"pending"` \| `"approved"` \| `"denied"`. | +| `lastPolledAt` | opt number | Last `/device/token` poll. | +| `pollingInterval` | opt number | 5000 (ms). | +| `clientId` | opt string | `"keel-cli"`. | +| `scope` | opt string | Unused (CLI sends none). | + +Indexes: `deviceCode`, `userCode`. Rows are deleted when consumed (token issued), denied-and-polled, +or expired-and-polled. + +#### `jwks` + +`publicKey` string (JWK JSON), `privateKey` string (JWK JSON encrypted with `BETTER_AUTH_SECRET`), +`createdAt`, `expiresAt` opt. RS256 key pairs for the Convex JWT bridge (§4.3). Not needed in Go. + +### 3.2 App-table fields tied to organizations (`convex/schema.ts`) + +| Table.field | Type | Meaning | +| --- | --- | --- | +| `projects.organizationId` | opt string | Owning org (`organization._id`). Unset only on rows from before organizations; adopted by `ensureDefault` (§6.3). Index `by_organization [organizationId]`, `by_slug [organizationId, slug]`. | +| `projects.ownerId` | opt string | Legacy per-user owner (`user._id`). Cleared on adoption. | +| `logSinks.organizationId` | opt string | Sink per org (index `by_organization`). Legacy rows have `projectId` instead. | +| `axiomSignIns.organizationId`, `axiomPending.organizationId` | string | Sign-in-with-Axiom state is per org. | + +### 3.3 Go storage (recommendation) + +Tables `users`, `accounts` (or a `password_hash` column on users), `sessions`, `organizations`, +`members` (UNIQUE(user_id) — makes "one membership" an invariant), `invitations`, `device_codes`. +Drop `verification`, `jwks`. Keep `sessions.token` UNIQUE and indexed; store it hashed +(SHA-256) if you want, but then existing tokens must be imported hashed the same way. An install +row (or `organizations` with a CHECK that allows one row) makes founding race-free (§6.3). + +--- + +## 4. Authentication mechanics (current) + +### 4.1 Where Better Auth lives + +- `authComponent.registerRoutes(http, createAuth, { cors: true })` mounts Better Auth at + `${CONVEX_SITE_URL}/api/auth/*` for `GET` and `POST` (prefix routes), plus `OPTIONS` preflight, + plus `GET /.well-known/openid-configuration` → `302` to + `${CONVEX_SITE_URL}/api/auth/convex/.well-known/openid-configuration`. +- The web app is served from a **different origin** (`SITE_URL`, nginx) than Better Auth + (`:3211`), hence the cross-domain plugin. +- Options (`createAuthOptions`): `baseURL: CONVEX_SITE_URL`, `trustedOrigins: [SITE_URL]`, + `advanced.database.generateId: false` (Convex assigns ids; required or the org plugin mints + invitation ids the component rejects), `emailAndPassword: { enabled: true, + requireEmailVerification: false }`, `databaseHooks` (§6.1), plugins `organization` (§7), + `deviceAuthorization` (§8), `crossDomain({ siteUrl })`, `convex({ authConfig, + jwksRotateOnTokenGenerationError: true })` (which embeds `bearer` and `jwt`). + +### 4.2 How a request carries the session + +1. **Browser (cross-domain plugin).** Responses carry `Set-Better-Auth-Cookie` (the Set-Cookie + value moved into a custom header; CORS exposes it). The client keeps a JSON map + `{cookieName: {value, expires}}` in `localStorage["better-auth_cookie"]` and sends + `Better-Auth-Cookie: better-auth.session_token=.; better-auth.convex_jwt=` + with `credentials: "omit"`. The server's before-hook copies it into a `Cookie` header unless an + `Authorization` header is present. The session cache lives in + `localStorage["better-auth_session_data"]`. `/sign-out` clears both keys client-side. +2. **Bearer (bearer plugin, CLI and CI).** `Authorization: Bearer ` (scheme + case-insensitive). If the token contains no `.`, the server signs it with the secret itself; if + it does, it is used as the signed cookie value. The HMAC is verified, then a synthetic + `Cookie: better-auth.session_token=` is set. Responses that set a session cookie also + return `set-auth-token: `. +3. **Cookie name.** `better-auth.session_token` (no `__Secure-` prefix: `baseURL` is http). + +### 4.3 The Convex JWT bridge (convex plugin) + +- `GET /api/auth/convex/token` (session required via `sessionMiddleware`; 401 `{"message": + "Unauthorized","code":"UNAUTHORIZED"}` without one) → `200 {"token": ""}` and sets cookie + `better-auth.convex_jwt` (max-age 900). After-hooks also set that cookie on `/sign-in*`, + `/sign-up*`, `/update-session`, `/get-session` (with a session); and clear it on `/sign-out`, + `/delete-user`, `/get-session` (without one). +- JWT: header `alg: RS256`, `kid` = `jwks._id`. Claims: every user field except `id` and `image` + (`name`, `email`, `emailVerified`, `createdAt`, `updatedAt`, …), `sessionId` = `session._id`, + `iat`, `sub` = `user._id`, `iss` = `CONVEX_SITE_URL`, `aud` = `"convex"`, `exp` = iat + 900 s. + Key pairs come from `jwks` (generated on first use; deleted and regenerated if signing fails with + `ERR_JOSE_NOT_SUPPORTED`, because `jwksRotateOnTokenGenerationError: true`). +- Convex verifies it with the `customJwt` provider from `auth.config.ts`: + `{ type: "customJwt", issuer: CONVEX_SITE_URL, applicationID: "convex", algorithm: "RS256", + jwks: CONVEX_SITE_URL + "/api/auth/convex/jwks" }`. +- `GET /api/auth/convex/jwks` → `{ keys: [{ kid, kty, alg, n, e, … }] }`. + `GET /api/auth/convex/.well-known/openid-configuration` → OIDC metadata (issuer, jwks_uri). +- **User resolution in every Convex function** (`authComponent.safeGetAuthUser`): + 1. `ctx.auth.getUserIdentity()` (the verified JWT) → none ⇒ signed out. + 2. `session` row with `_id == identity.sessionId` **and** `expiresAt > now` → none ⇒ signed out. + (So sign-out takes effect on the next query even though the JWT lives 15 min.) + 3. `user` row with `_id == identity.subject` → none ⇒ signed out. + 4. Returns the full user doc `{_id, _creationTime, name, email, emailVerified, image?, createdAt, + updatedAt, userId?}`. +- Actions propagate the caller's identity into `ctx.runQuery`/`ctx.runMutation`, so internal + queries such as `nodesInternal.owned`, `logSinks.forNode`, `tracing.scope` apply the caller's + access rules. + +Go: no JWT. Resolve `Actor` directly from the session token (cookie `keel_session` or bearer) on +every request and on WebSocket connect, applying the same three checks (session exists, unexpired, +user exists). + +### 4.4 Session lifetime + +- `expiresIn` 7 days, `updateAge` 1 day: any session read (`get-session`, `convex/token`, + `sessionMiddleware`) where `expiresAt − 7d + 1d ≤ now` pushes `expiresAt` to now + 7 d and + re-sets the cookie. In query context (no mutation available) refresh is disabled. +- An expired session found on read is deleted and the cookie cleared. +- CLI sessions therefore last 7 days and renew while used (each run hits `/convex/token`). +- No cleanup cron; expired rows linger until touched. + +### 4.5 Passwords (exact, for importing existing accounts) + +- Length: min 8, max 128 characters (server); errors below. +- Hash format: `":"`. `saltHex` = 16 random bytes as 32 lowercase hex chars. Key = + `scrypt(P = NFKC(password) as UTF-8, S = saltHex **as its ASCII bytes (not hex-decoded)**, + N = 16384, r = 16, p = 1, dkLen = 64)`, hex-encoded (128 chars). Verify by recomputing and + comparing hex strings. Go: `scrypt.Key([]byte(norm.NFKC.String(pw)), []byte(saltHex), 16384, 16, + 1, 64)`; compare with `subtle.ConstantTimeCompare`. + +### 4.6 Origin / CSRF / CORS / rate limits + +- **CORS on `/api/auth/*`** (convex-helpers `corsRouter`): allowed origins = `[SITE_URL]`; + `Access-Control-Allow-Credentials: true`; allowed headers `Content-Type, Better-Auth-Cookie, + Authorization`; exposed `Set-Better-Auth-Cookie`; `Vary: Origin`; preflight `204` with + `Access-Control-Allow-Methods` (`GET` or `POST` per prefix) and `Access-Control-Max-Age: 86400`. + Disallowed origins are **not** blocked (`enforceAllowOrigins: false`), they just get no + `Access-Control-Allow-Origin`. +- **Origin check** (Better Auth, every non-GET/HEAD/OPTIONS request): if the request carries a + `Cookie` (including one synthesized from `Better-Auth-Cookie` or, depending on hook order, a + bearer), `Origin` (or `Referer`) must be present and match `SITE_URL`: else `403` + `{"code":"MISSING_OR_NULL_ORIGIN","message":"Missing or null Origin"}` or + `{"code":"INVALID_ORIGIN","message":"Invalid origin"}`. Sign-in/sign-up also validate `Origin` + when one is sent, and block `Sec-Fetch-Site: cross-site` + `Sec-Fetch-Mode: navigate`. Any + `callbackURL`/`redirectTo`/`errorCallbackURL`/`newUserCallbackURL` in a body must be a trusted + origin or relative path. +- **The CLI and CI always send `Origin: `** (scheme://host[:port] as the user typed + it) on every Better Auth call, precisely to satisfy this. Quirk: if the user logs in through a + hostname other than `SITE_URL`'s (e.g. MagicDNS name vs IP), cookie-bearing POSTs fail with + "Invalid origin". +- **Rate limit** (only when Better Auth considers itself in production): in-memory, per IP and + path, 100 requests / 10 s by default; `/sign-in*`, `/sign-up*` 3 / 10 s. Response 429 with + `X-Retry-After`. Best-effort in Convex's isolates. + +Go: same-origin dashboard + API, so no CORS and no cross-domain header dance. Keep CSRF protection +for cookie-authenticated unsafe methods (check `Origin` against `KEEL_SITE_URL` or use +SameSite=Lax + a custom header requirement). Do not require `Origin` on bearer requests (no ambient +credential), but accept it. Add a modest login rate limit (e.g. 5 / min / IP on sign-in, sign-up, +device code). + +### 4.7 Better Auth error body shape + +Errors are JSON `{"message": "...", "code": "..."}` with the HTTP status of the `APIError` +(`code` may be absent for hand-thrown errors). Device endpoints instead answer RFC 8628 style +`{"error": "...", "error_description": "..."}`. The web shows `error.message` (or +`error_description` on `/device`); the CLI reads `message` or `error_description`. + +--- + +## 5. Better Auth HTTP endpoints Keel uses + +All under `${CONVEX_SITE_URL}/api/auth`. "Session" = cookie or bearer as in §4.2. + +| # | Method, path | Caller | Auth | Request | 200 response | +| --- | --- | --- | --- | --- | --- | +| 1 | `POST /sign-up/email` | web sign-up form, CI | none | `{email, password, name, invitationId?}` (+ optional `image`, `callbackURL`, `rememberMe`) | `{token, user}` and session cookie (auto sign-in) | +| 2 | `POST /sign-in/email` | web sign-in form, CI | none | `{email, password}` (+ `callbackURL?`, `rememberMe?`) | `{redirect: false, token, url: undefined, user}` + cookie | +| 3 | `POST /sign-out` | web (several buttons), `keel logout` | session (optional) | `{}` | `{success: true}`; deletes the session row if one was presented | +| 4 | `GET /get-session` | web `useSession` (ConvexBetterAuthProvider) | session | — | `{session, user}` or `null` | +| 5 | `GET /convex/token` | web provider (`fetchAccessToken`), CLI `Connect` | session | — | `{token}` (JWT) | +| 6 | `GET /convex/jwks` | Convex itself | none | — | JWKS | +| 7 | `POST /organization/invite-member` | web invite dialog | session | `{email, role: "member", organizationId}` | invitation object | +| 8 | `POST /organization/accept-invitation` | web invite page (signed in) | session | `{invitationId}` | `{invitation, member}` | +| 9 | `POST /device/code` | CLI `StartLogin` | none | `{client_id: "keel-cli"}` | `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}` | +| 10 | `POST /device/token` | CLI `PollLogin` | none | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id: "keel-cli"}` | `{access_token, token_type: "Bearer", expires_in, scope: ""}` | +| 11 | `GET /device?user_code=X` | web `/device` page, CI | session optional (binds when present) | query `user_code` | `{user_code, status}` | +| 12 | `POST /device/approve` | web `/device`, CI | session required | `{userCode}` | `{success: true}` | +| 13 | `POST /device/deny` | web `/device` | session required | `{userCode}` | `{success: true}` | + +`user` JSON (Better Auth output): `{id, name, email, emailVerified, image, createdAt, updatedAt}` +(dates as ISO strings). `session` JSON: `{id, token, userId, expiresAt, createdAt, updatedAt, +ipAddress, userAgent, activeOrganizationId}`. + +### 5.1 `POST /sign-up/email` + +Order of checks (Better Auth 1.6.17 + Keel hooks): + +1. Body schema (zod): missing/invalid fields → 400 `VALIDATION_ERROR`. +2. Email syntax → 400 `{"code":"INVALID_EMAIL","message":"Invalid email"}`. +3. Password missing/non-string → 400 `INVALID_PASSWORD`; `< 8` → 400 `{"code":"PASSWORD_TOO_SHORT", + "message":"Password too short"}`; `> 128` → 400 `PASSWORD_TOO_LONG` "Password too long". +4. Email lower-cased. Existing user with that email → **422** + `{"code":"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL","message":"User already exists. Use another email."}` + (checked **before** Keel's hook, so a taken email never reveals the invite rule). +5. Password hashed (§4.5). +6. `user.create.before` hook (§6.1): may throw **403** `{"message":"Sign-up is by invitation. Ask + a member for an invite link."}` (or 403 "Sign-up needs a request" when called without an HTTP + endpoint context; unreachable from HTTP). +7. User row inserted (`emailVerified: false`), then `user.create.after` hook (§6.1). +8. `account` row `{providerId: "credential", accountId: user.id, password: hash}`. +9. Session created (passing through `session.create.before`), cookie set, response + `{token: session.token, user}`. + +Note: Better Auth wraps this in `runWithTransaction`, but the Convex adapter runs each adapter +call as its own mutation, so steps 7–9 are **not atomic** today (a crash between them can leave a +user without account/member). Go: one DB transaction for user + account + member + invitation +update + session. + +### 5.2 `POST /sign-in/email` + +1. Email syntax → 400 "Invalid email". +2. User by lower-cased email; missing user, missing credential account, or missing/wrong password → + **401** `{"code":"INVALID_EMAIL_OR_PASSWORD","message":"Invalid email or password"}` (a dummy + hash is computed on the miss paths to equalize timing). +3. Session created (hook sets `activeOrganizationId`), cookie set, returns + `{redirect, token, url, user}`. CI asserts `.token` is present. + +No organization requirement: a user without membership can sign in. + +### 5.3 Device authorization endpoints (plugin config: `verificationUri: ${SITE_URL}/device`, +`validateClient: id => id === "keel-cli"`, defaults `expiresIn 30m`, `interval 5s`, +`deviceCodeLength 40`, `userCodeLength 8`) + +`POST /device/code` + +- `client_id !== "keel-cli"` → 400 `{"error":"invalid_client","error_description":"Invalid client ID"}`. +- Inserts `deviceCode {deviceCode, userCode, userId: null, expiresAt: now+30m, status: "pending", + pollingInterval: 5000, clientId, scope}`. +- Response (header `Cache-Control: no-store`): `device_code`, `user_code` (8 chars, no dash), + `verification_uri: "/device"`, `verification_uri_complete: + "/device?user_code="`, `expires_in: 1800`, `interval: 5`. + +`POST /device/token` (all errors 400 unless noted, body `{error, error_description}`) + +1. `client_id` invalid → `invalid_grant` "Invalid client ID". +2. No row for `device_code` → `invalid_grant` "Invalid device code". +3. Row `clientId` ≠ `client_id` → `invalid_grant` "Client ID mismatch". +4. `lastPolledAt` set and `now − lastPolledAt < pollingInterval (5000 ms)` → `slow_down` + "Polling too frequently" (**`lastPolledAt` is not updated in this case**). +5. Set `lastPolledAt = now`. +6. Expired → delete row, `expired_token` "Device code has expired". +7. `status == "pending"` → `authorization_pending` "Authorization pending". +8. `status == "denied"` → delete row, `access_denied` "Access denied". +9. `status == "approved"` and `userId` set → atomically consume (find-and-delete the row where + `deviceCode` matches and `status == "approved"`); lost race → `invalid_grant` "Invalid device + code". Load user (500 `server_error` "User not found" if gone), create a session for it (500 + `server_error` "Failed to create session" on failure). Response 200, headers + `Cache-Control: no-store`, `Pragma: no-cache`: `{access_token: session.token, token_type: + "Bearer", expires_in: , scope: ""}`. **The token is handed out once.** +10. Anything else → 500 `server_error` "Invalid device code status". + +`GET /device?user_code=X` (no Origin check, GET) + +- `user_code` with `-` removed (case is **not** normalised server-side; the web upper-cases and + strips spaces/dashes before calling). +- Unknown → 400 `invalid_request` "Invalid user code". Expired → 400 `expired_token` "User code + has expired". +- If the caller has a session, the row has no `userId` and `status == "pending"`: conditional + update (`id` match, `status == "pending"`, `userId IS NULL`) sets `userId = caller`. This + **binds the code to the first signed-in user who looks at it**. +- Response `{user_code: , status: }`. + +`POST /device/approve` / `POST /device/deny` (body `{userCode}`) + +- No session → 401 `unauthorized` "Authentication required". +- Unknown → 400 `invalid_request` "Invalid user code"; expired → 400 `expired_token` "User code has + expired"; `status != "pending"` → 400 `invalid_request` "Device code already processed"; + `userId` null → 400 `invalid_request` "Device code has not been claimed by a verifying session; + call `GET /device` with the `user_code` while signed in before approving or denying"; bound to + another user → **403** `access_denied` "You are not authorized to approve this device + authorization" / "You are not authorized to deny this device authorization". +- Sets `status = "approved"` / `"denied"`, `userId = caller`. Returns `{success: true}`. + +Note: approval does **not** require the approver to be in an organization. The CLI then gets a +session for that account, organization or not (founder gotcha, §6.4). + +### 5.4 Exposed but unused Better Auth endpoints (do not port) + +Core: `/ok` (GET `{ok:true}`), `/error`, `/update-session`, `/list-sessions`, `/revoke-session`, +`/revoke-sessions`, `/revoke-other-sessions`, `/update-user`, `/change-password`, `/change-email`, +`/delete-user`, `/delete-user/callback`, `/verify-password`, `/request-password-reset`, +`/reset-password`, `/reset-password/:token`, `/send-verification-email`, `/verify-email`, +`/sign-in/social`, `/callback/:id`, `/link-social`, `/unlink-account`, `/list-accounts`, +`/account-info`, `/get-access-token`, `/refresh-token`. Cross-domain: +`/cross-domain/one-time-token/verify` (OAuth only). Organization: `/organization/create` (always +403 "You are not allowed to create a new organization"), `check-slug`, `update`, `delete`, +`get-full-organization`, `set-active`, `list`, `remove-member`, `update-member-role`, +`get-active-member`, `get-active-member-role`, `leave`, `list-members`, `reject-invitation`, +`cancel-invitation`, `get-invitation`, `list-invitations`, `list-user-invitations`. (Teams and +dynamic roles are disabled.) Today an owner could remove members or cancel invitations only via +these raw endpoints; nothing in the UI or CLI does. + +--- + +## 6. Sign-up, founding, joining + +### 6.1 Database hooks (`convex/auth.ts`) + +```ts +user.create.before(user, endpoint): + adapter = endpoint?.context.adapter + if !adapter: throw APIError("FORBIDDEN", "Sign-up needs a request") + if no row in `user` (findMany limit 1): return // first account: allowed + id = endpoint.body.invitationId if non-empty string else null + inv = id ? pendingInvitation(id) : null // see below + if !inv || !sameEmail(inv.email, user.email): + throw APIError("FORBIDDEN", "Sign-up is by invitation. Ask a member for an invite link.") + +user.create.after(user, endpoint): + id = endpoint.body.invitationId (non-empty string) ; if !adapter || !id: return + inv = pendingInvitation(id); if !inv || !sameEmail(inv.email, user.email): return + update invitation id=inv.id set status = "accepted" + create member { organizationId: inv.organizationId, userId: user.id, + role: inv.role ?? "member", createdAt: now } + +session.create.before(session, endpoint): + member = first member where userId = session.userId + return { data: { ...session, activeOrganizationId: member?.organizationId ?? null } } + +pendingInvitation(id): row = invitation by id (malformed id → null); + null unless row.status == "pending" and row.expiresAt >= now +sameEmail(a, b) = a.trim().toLowerCase() == b.trim().toLowerCase() +``` + +Consequences: + +- The first account may send an `invitationId` (ignored by `before`); `after` still honours a + valid one (only possible if an org and invitation already exist, e.g. after users were deleted). +- `after` does not check the org's membership limit or the inviter's current membership. +- The member row exists before the session is created, so an invited user's first session already + has `activeOrganizationId`. +- The founder's sign-up session has `activeOrganizationId: null` forever (not updated on founding). + +### 6.2 `auth.signUpOpen` (public query) + +Args `{}`. Returns `true` iff the `user` table is empty. No auth. The web shows the sign-up form +(title "Lay the keel") only while this is `true` (or on an invite link). + +### 6.3 Founding: `joinOrFound` (`projects.ts`, used by `projects.ensureDefault` and +`projects.create`) + +```ts +user = requireUser(ctx) // ConvexError("Not authenticated") +m = currentMembership(ctx); if m: return m +if any organization row exists: throw ConvexError(NO_ORGANIZATION) +org = create organization { name: "Default", slug: "default", createdAt: now } +create member { organizationId: org._id, userId: user._id, role: "owner", createdAt: now } +return { user, organizationId: org._id, role: "owner" } +``` + +`NO_ORGANIZATION` = `"You're not in an organization yet. Ask a member for an invite link."` + +Runs inside a Convex mutation (serializable, retried on conflict), so two concurrent founders +cannot create two orgs: the loser re-runs, sees the org, and gets `NO_ORGANIZATION`. Go: do the +"org exists?" check and both inserts in one write transaction (single writer / `BEGIN +IMMEDIATE`), or rely on a unique "one org" constraint. + +**`projects.ensureDefault`** (public mutation, args `{}`, returns the project slug string). Called +by the web's `/` route (`Bootstrap`) on every visit while signed in; on success navigates to +`/p/`; on error shows the message and a "Sign out" button. + +1. `membership = joinOrFound()`. +2. Legacy adoption (installs from before organizations): `legacy` = projects with + `organizationId` unset; `own` = projects of `membership.organizationId`. `taken` = slugs of + `own`. Sort `legacy` so rows with `ownerId == user._id` come first. For each: `slug = + uniqueName(p.slug, taken)` (base, else `base-2`, `base-3`, …), add to `taken`, patch + `{organizationId, ownerId: undefined, slug, name: slug == p.slug ? p.name : slug}`. +3. `existing = legacy.find(ownerId == user._id) ?? own[0] ?? legacy[0]`; if any → return its + (possibly renamed) slug. +4. Else insert project `{name: "acme-support", slug: "acme-support", organizationId}` plus + environment `{projectId, name: "production", isProduction: true}`; return `"acme-support"`. + +So any member (not only the founder) who opens `/` in an org with zero projects creates the +default project. + +**`projects.create`** founds too (so `keel project create` works on a fresh install), then +validates the name (details belong to the projects spec): trim; `""` or `> 60` → +`"Project name: 1–60 characters"`; slug = NFKD, strip combining marks, lower-case, runs of +`[^a-z0-9]` → `-`, cut to 40, trim `-`; empty → `"Project name needs a letter or digit (a-z, +0-9)"`; taken in org → `` `Project "${slug}" already exists` ``. + +**`projects.list`** special case: no membership → `requireUser` (signed out ⇒ `"Not +authenticated"`); if an org exists ⇒ throw `NO_ORGANIZATION`; else (nothing founded yet) return +`[]`. + +### 6.4 The "founder without org" gotcha + +On a fresh install the first account is often created from a `keel login` link: the CLI prints +`/device?user_code=…`, the `_auth` layout shows sign-up (since `signUpOpen`), the user +signs up and stays on `/device`, approves, and never visits `/`. `ensureDefault` never runs, so +**no organization exists** although an account does. Then: + +- `organizations.current` → `null`; `keel login` / `keel whoami` warn "this account isn't in the + install's organization yet; projects stay empty until a member invites it". +- `projects.list` → `[]` (not an error, because no org exists yet); `keel project create` founds + the org (`joinOrFound`) and the CLI proceeds. CI covers exactly this + (`keel project list` → `[]`, then `project create` succeeds). +- Every other org-scoped read returns `null`/`[]`, mutations throw "Environment not found" / + "Node not found" / `NO_ORGANIZATION` as per §9. +- Meanwhile nobody else can join: sign-up needs an invitation and there is no org to invite into. + +Go must keep both founding entry points (dashboard bootstrap and project create) or found the org +eagerly at first sign-up (simpler, removes the gotcha; if chosen, `GET /api/me` and +`listProjects` behave as if the founder always has a membership, and the CLI warning path becomes +unreachable but must still be supported for legacy accounts without membership). + +### 6.5 Accounts without a membership after the org exists + +Possible for: legacy users from before organizations (the first to sign in founded the org), and +the loser of a first-sign-up race. They can sign in; `/` shows `NO_ORGANIZATION` with a "Sign +out" button; the CLI maps it to `NO_ORGANIZATION`. Their way in is an invite link while signed in +(§7.3), which is currently broken (§7.4). + +--- + +## 7. Organizations and invitations + +### 7.1 Plugin config + +`organization({ allowUserToCreateOrganization: false, invitationExpiresIn: 604800, +cancelPendingInvitationsOnReInvite: true, sendInvitationEmail: async () => {} })` with default +roles: + +| Role | Better Auth permissions (relevant) | +| --- | --- | +| `owner` | `invitation: [create, cancel]`, `member: [create, update, delete]`, `organization: [update, delete]` | +| `admin` | `invitation: [create, cancel]`, `member: [create, update, delete]`, `organization: [update]` | +| `member` | none (`ac: [read]` only) | + +`creatorRole` = `owner`: only a member whose role includes `owner` may invite someone **as** +`owner`. Defaults: `invitationLimit` 100 pending unexpired invitations per org, +`membershipLimit` 100 (checked on accept only). + +### 7.2 `POST /organization/invite-member` (web: Account menu → "Invite people…") + +Request from the web: `{ email: email.trim(), role: "member", organizationId: }`. + +1. Session required (401 otherwise). `organizationId = body.organizationId || + session.activeOrganizationId`; none → 400 "Organization not found". +2. `email` lower-cased; invalid → 400 "Invalid email". +3. Caller's member row in that org; none → 400 `{"code":"MEMBER_NOT_FOUND","message":"Member not found"}`. +4. Permission `invitation:create` on the caller's role; `member` role fails → **403** + "You are not allowed to invite users to this organization". (The web shows the menu item to + every member; members get this toast.) +5. Role must be a known role → 400 "Role not found: ". Inviting as `owner` without being + owner → 403 "You are not allowed to invite a user with this role". +6. A user with that email already a member of the org → 400 "User is already a member of this + organization". +7. Pending unexpired invitation for (email, org) exists → since `cancelPendingInvitationsOnReInvite` + it is set `status: "canceled"` (no "already invited" error). +8. Pending unexpired invitations in the org `>= 100` → 403 "Invitation limit reached". +9. Insert `{organizationId, email, role, status: "pending", expiresAt: now + 7 d, createdAt: now, + inviterId: caller}`. `sendInvitationEmail` is a no-op (nothing is mailed). +10. Response: the invitation `{id, organizationId, email, role, status, expiresAt, createdAt, + inviterId, …}`. The web builds the link `${window.location.origin}/invite/${data.id}` (the + browser's origin, not `SITE_URL`) and shows it with a Copy button; dialog copy: "Send this link + to them. It works once, for that email, for a week." + +### 7.3 Invite link page `/invite/$invitationId` (public route) + +- Reads `organizations.invitation({ id })`. `null` → "Invite not found" / "This invite link is + unknown, already used or expired. Ask a member for a new one." +- Signed out → sign-up form with email prefilled and disabled, title `Join `, submit + sends `invitationId` in the sign-up body (§5.1/§6.1). On success → `/` (Bootstrap → project). +- Signed in, same email (case-insensitive) → "Join" button → `POST + /organization/accept-invitation {invitationId}` → on success `/`; on error toast + `error.message ?? "Could not accept the invitation"`. +- Signed in, other email → "This invite is for X, but you are signed in as Y. Sign out to create + that account." + Sign out. + +### 7.4 `POST /organization/accept-invitation` (current behaviour, including a bug) + +1. Session required. Invitation by id; missing, expired, or `status != "pending"` → 400 + "Invitation not found". +2. Invitation email ≠ session user email (lower-cased) → 403 "You are not the recipient of the + invitation". +3. **Email verification gate.** Better Auth requires `user.emailVerified` for invitation-id actions + unless invitation ids are generated by its built-in opaque generator. Keel sets + `advanced.database.generateId: false`, so the gate is on, and `emailVerified` is always `false` + in Keel. **Result: this endpoint always fails today with 403 "Email verification required + before accepting or rejecting invitation".** The "Join" path for existing accounts does not + work; only sign-up-with-invitation works. +4. (Would continue:) member count ≥ 100 → 403 "Organization membership limit reached"; set + invitation `accepted` (conditional on `pending`); create member `{organizationId, userId, role: + invitation.role}`; set the session's `activeOrganizationId`; on failure revert the invitation to + `pending`. Response `{invitation, member}`. + +Go: implement the intended behaviour (steps 1, 2, 4 without step 3): accept when the invitation is +pending, unexpired, and its email matches the signed-in user's; refuse with the same messages; also +refuse if the user already has a membership (one-membership invariant) with a clear message, e.g. +"You're already in an organization". The unguessable invitation id is the proof of possession. + +### 7.5 `organizations.current` (public query) + +Args `{}`. `membership = currentMembership()`; none → `null`. Load organization by +`membership.organizationId`; missing → `null`. Returns `{ id: org._id, name, slug, role: +membership.role }`. Used by the account menu (shows `" · "`, gates "Invite people…"), +Settings ("every project in "), the CLI (`whoami`, login warning; JSON `{id, name, slug, +role}` printed as the CLI's `organization`). Also called by `logSinks.connectAxiom` and +`logSinks.provision` via `ctx.runQuery(api.organizations.current)` as a membership gate. + +### 7.6 `organizations.invitation` (public query, no auth) + +Args `{ id: string }`. Invitation by `_id` (malformed id → `null`); `null` unless `status == +"pending"` and `expiresAt >= now`; organization by id, missing → `null`. Returns `{ email: +invitation.email, organization: org.name }`. Public on purpose: "the id is the secret in the link". + +### 7.7 `auth.getCurrentUser` (public query) + +Args `{}`. Returns `safeGetAuthUser()` (§4.3) or `null`. Shape: the raw user doc +`{_id, _creationTime, name, email, emailVerified, image?, createdAt, updatedAt, userId?}`. Web uses +`name`, `email`; the CLI reads `_id`, `email`, `name` and prints `{id, email, name}`; a `null` makes +the CLI say "Session expired or signed out" (`NOT_AUTHENTICATED`). + +### 7.8 Template leftovers + +- `privateData.get` (public query, args `{}`): `{message: "Not authenticated"}` when signed out, + else `{message: "This is private"}`. No caller. Do not port. +- `healthCheck.get` (public query, no args): returns `"OK"`. No caller in web/CLI/install. Go: + a plain `GET /api/health` → `200 "OK"` if wanted. + +--- + +## 8. CLI authentication (current, `apps/cli`) + +### 8.1 Discovery + +`keel login `: URL normalised to `scheme://host[:port]` (http/https only; else usage +error `"" is not a dashboard URL (http:// or https:// and a host)`). `GET /config.js`; +take the text between the first `{` and the last `}` as JSON `{convexUrl, convexSiteUrl}`. Non-200, +no braces or bad JSON → `DISCOVERY_FAILED` "`` doesn't look like a Keel dashboard (no +/config.js)"; empty values → `DISCOVERY_FAILED` "``/config.js has no Convex URLs (a dev +server?)". `--convex-url` / `--convex-site-url` (or `KEEL_CONVEX_URL` / `KEEL_CONVEX_SITE_URL` with +`KEEL_URL`) skip discovery. `config.js` is written by the web container: +`window.__KEEL__ = {"convexUrl":"","convexSiteUrl":""};` Keep the file and its shape +(CLAUDE.md); the Go architecture adds `GET /api/meta` for new CLIs. + +### 8.2 Login state machine + +Config file `~/.config/keel/config.json` (`$KEEL_CONFIG_DIR`, else `$XDG_CONFIG_HOME/keel`; 0600 in a +0700 dir, atomic rename). Per instance: `url`, `convexUrl`, `convexSiteUrl`, `email`, `token` +(raw session token), `pending {deviceCode, userCode, url, expiresAt, interval}`. + +``` +keel login: + if inst.token: Connect(); NOT_AUTHENTICATED → drop token; ok → print loggedIn, done + if inst.pending and not expired: PollLogin once + NOT_AUTHENTICATED (expired/denied/used) → drop pending; got token → use it + if no token and (no pending or expired): StartLogin → POST /api/auth/device/code {client_id} + pending = {device_code, user_code, verification_uri_complete, + expiresAt = now + expires_in (UTC, truncated to s), interval = max(interval,1)} + save config + if no token: + no TTY or --json or --no-wait (and not --wait): print pending result and exit 0 + {"ok":true,"status":"pending","instance","url","approvalUrl","code":"ABCD-EFGH","expiresAt","next"} + else: poll every `interval` s (+5 s per slow_down) until token / error / Ctrl-C (CANCELLED) + save token BEFORE anything else can fail (token is single-use), clear pending + Connect() → whoami → save email → print {"status":"loggedIn","instance","url","user","organization"} + organization == null → warning (§6.4) +``` + +`PollLogin` maps `/device/token` replies: 200 with `access_token` → token; `authorization_pending` +→ wait; `slow_down` → wait longer; `expired_token` → `NOT_AUTHENTICATED` "The login link expired +before anyone approved it"; `access_denied` → `NOT_AUTHENTICATED` "The login was denied in the +dashboard"; `invalid_grant` → `NOT_AUTHENTICATED` "The login link is used up or unknown"; other +`error_description` → `SERVER_ERROR` "/api/auth/device/token: "; else `SERVER_ERROR` "HTTP +". + +Every other command runs `finishLogin` first when the instance has a pending login and no token: +expired → `NOT_AUTHENTICATED` (no poll); poll once; on `slow_down` wait `interval` s and poll once +more; still pending → `AUTHORIZATION_PENDING` (exit 4) with fix "Open and approve (agents: +send it to your human), then retry; or keel login --wait"; on error re-read the config file: if a +concurrent run saved a token, use it; if the code is gone (denied/expired/used) and still the same +`deviceCode`, drop `pending` from the file. Success → save token, progress line "Login approved; +saved for ". + +### 8.3 Authenticated calls + +1. `Connect`: no token → `NOT_AUTHENTICATED` "Not logged in". `GET + /api/auth/convex/token` with `Authorization: Bearer ` and `Origin: ` + → `{token: JWT}`. 401 → `NOT_AUTHENTICATED` "Session expired or signed out". JWTs are never + cached (15-min life). +2. Each function call: `POST /api/{query|mutation|action}` body + `{"path":"module:function","args":{…},"format":"json"}` (nil args → `{}`), `Authorization: + Bearer `. Response `{"status":"success","value":…}` or `{"status":"error","errorMessage", + "errorData"}`. Non-JSON + HTTP 401 → unauthenticated. `errorMessage` is cleaned (drop + `[Request ID: …] `, `Server Error\n`, `Uncaught `, stack after `\n at `). +3. Function paths the CLI calls: `auth:getCurrentUser`, `organizations:current`, `projects:list`, + `projects:create`, `environments:summary`, `nodes:list`, `nodes:create`, `nodes:remove`, + `variables:list`, `variables:set`, `variables:remove`, `deployments:start`, + `deployments:latest`, `deployments:get`, `deployments:listForNode`, `logs:tail`, + `traces:overview`, `tracing:forNode`, `tracing:enable`, `tracing:localEnv`, `tracing:prompt`. +4. `keel logout`: `POST /api/auth/sign-out` with the bearer (failure only warns), then forget the + instance and its directory links. Refused when `KEEL_URL` is set. +5. `keel token` prints the raw session token (after `finishLogin`) for `KEEL_TOKEN`. +6. Env: `KEEL_URL` (+ `KEEL_TOKEN`, `KEEL_CONVEX_URL`, `KEEL_CONVEX_SITE_URL`) targets an install + without the config file; `KEEL_TOKEN` overrides a stored token; `KEEL_INSTANCE` / `--instance` + pick a saved one. + +### 8.4 Error translation (CLI `translate`, keyed on `ConvexError` data strings) + +| ConvexError message (exact) | CLI code | CLI message / fix | +| --- | --- | --- | +| `Not authenticated` | `NOT_AUTHENTICATED` (exit 4) | "Session expired or signed out" | +| starts with `You're not in an organization` | `NO_ORGANIZATION` | "This account isn't in the install's organization"; fix "Ask a member for an invite link (account menu → Invite people), then keel login " | +| `A deployment is already running` | `DEPLOYMENT_RUNNING` | | +| `Nothing to ship` | `NOTHING_TO_SHIP` | | +| `Node not found` | `SERVICE_NOT_FOUND` | "Service not found" | +| `Connect Axiom to see traces` or `Sign in with Axiom again to turn on traces` | `TRACES_OFF` | message kept | +| `Environment not found` | `PROJECT_NOT_FOUND` | | +| `Project "" already exists` | `NAME_TAKEN` | fix "Pick another name, or use it: keel link " | +| ends with `" is already taken` | `NAME_TAKEN` | | +| any other non-empty string | `INVALID_INPUT` | message kept | +| no data (non-ConvexError throw) | `SERVER_ERROR` | | +| HTTP 401 from Convex | `NOT_AUTHENTICATED` | | + +Exit codes: 0 ok, 1 error, 2 usage, 4 `NOT_AUTHENTICATED`/`AUTHORIZATION_PENDING`, 130 cancelled. +Go: the server returns the code directly (problem+json `code`), so the CLI no longer parses +messages, but **messages stay byte-identical** (ARCHITECTURE: `domain.Error.Message` = Convex +message) because the dashboard displays them. + +--- + +## 9. Access control (`convex/access.ts`) + +### 9.1 Helpers (exact rules) + +| Helper | Rule | Returns / throws | +| --- | --- | --- | +| `requireUser(ctx)` | `safeGetAuthUser` (§4.3) | user doc, else throws `ConvexError("Not authenticated")` | +| `currentMembership(ctx)` | user (silent) → first `member` row with `userId == user._id` | `{user, organizationId, role}` or `null` (signed out **or** no membership; never throws) | +| `ownedProject(ctx, projectId)` | membership exists **and** project exists **and** `project.organizationId === membership.organizationId` | project or `null` (projects with unset `organizationId` are never owned) | +| `ownedEnvironment(ctx, envId)` | environment exists, then `ownedProject(environment.projectId)` | `{environment, project}` or `null` | +| `ownedNode(ctx, nodeId)` | node exists, then `ownedEnvironment(node.environmentId)` | `{node, environment, project}` or `null` | +| `requireEnvironment(ctx, envId)` | `ownedEnvironment` | scope, else throws `ConvexError("Environment not found")` | +| `requireNode(ctx, nodeId)` | `ownedNode` | scope, else throws `ConvexError("Node not found")` | +| `logSinks.requireOrganization(ctx)` (local) | `currentMembership` | `organizationId`, else throws `NO_ORGANIZATION` (also when signed out) | + +Key property: **signed out, not a member, missing, and foreign are indistinguishable** for every +resource lookup ("not found"/`null`/`[]`). Only `requireUser`-based paths say "Not authenticated". +Go: keep this (no 403 that leaks existence; use 404 codes from §8.4's mapping: `PROJECT_NOT_FOUND` +for environments, `SERVICE_NOT_FOUND` for nodes), but signed-out requests to authenticated API +routes may short-circuit with 401 `NOT_AUTHENTICATED` in middleware, since today's web never calls +them signed out and the CLI maps both to re-login. Exception to preserve: endpoints that are +public today (§9.3). + +Convex argument validators (`v.id("nodes")` etc.) reject malformed ids or ids of another table +before the handler with a non-`ConvexError` ("ArgumentValidationError"), which the CLI reports as +`SERVER_ERROR`. Go: treat an unknown/malformed id exactly like a foreign one. + +### 9.2 Validators in `access.ts` (exact messages) + +| Function | Rule | Error | +| --- | --- | --- | +| `validName(name)` | `/^[a-z0-9-]{1,40}$/` | `Name: 1–40 chars, a-z 0-9 and - only` | +| `validEnvKey(key)` | `/^[A-Z_][A-Z0-9_]{0,63}$/` | `Key: UPPER_SNAKE_CASE only` | +| `validImage(image)` | `/^[a-z0-9][a-z0-9._\-/:@]{0,199}$/` | `Image must look like repo/name:tag` | +| `validPort(port?)` | undefined, or integer 1–65535 | `Port must be 1–65535` | +| `validReplicas(n?)` | undefined, or integer 0–20 | `Replicas must be 0–20` | + +(The `–` in these messages is U+2013 EN DASH.) + +### 9.3 Guard of every public function + +"Denied" covers signed out, no membership, missing and foreign alike unless stated. + +| Function | Kind | Guard | When denied | +| --- | --- | --- | --- | +| `auth.getCurrentUser` | query | none | returns `null` | +| `auth.signUpOpen` | query | **public** | — | +| `organizations.current` | query | `currentMembership` | `null` | +| `organizations.invitation` | query | **public** (id is the secret) | `null` | +| `privateData.get`, `healthCheck.get` | query | none | (leftovers) | +| `projects.ensureDefault` | mutation | `joinOrFound` | `Not authenticated` / `NO_ORGANIZATION` | +| `projects.create` | mutation | `joinOrFound` | `Not authenticated` / `NO_ORGANIZATION` | +| `projects.getBySlug` | query | `currentMembership`, slug looked up inside the caller's org | `null` | +| `projects.list` | query | `currentMembership` | signed out: `Not authenticated`; org exists: `NO_ORGANIZATION`; no org yet: `[]` | +| `environments.summary` | query | `ownedEnvironment` | `null` | +| `nodes.list` | query | `ownedEnvironment` | `[]` | +| `nodes.create` | mutation | `requireEnvironment` | `Environment not found` | +| `nodes.move`, `rename`, `setDesired`, `stop`, `start`, `expose`, `unexpose`, `duplicate` | mutation | `requireNode` | `Node not found` | +| `nodes.remove` | mutation | `ownedNode` | silent no-op (idempotent multi-delete) | +| `nodes.publicAddress` | query | `requireUser` only (no org needed) | `Not authenticated` | +| `variables.list`, `variables.referenceable` | query | `ownedNode` | `[]` | +| `variables.set`, `variables.remove` | mutation | `requireNode` | `Node not found` | +| `deployments.start` | mutation | `requireEnvironment` | `Environment not found` | +| `deployments.latest` | query | `ownedEnvironment` | `null` | +| `deployments.get` | query | deployment's `ownedEnvironment` (arg is a free string, normalised) | `null` | +| `deployments.listForNode` | query | `ownedNode` | `[]` | +| `logs.tail` | action | `internal.logSinks.forNode` → `ownedNode` | `Node not found` | +| `logs.recent`, `logs.around` | action | `internal.logSinks.forEnvironment` → `ownedEnvironment` | `Environment not found` (then `Connect Axiom to search all logs` without a sink) | +| `traces.overview`, `traces.get`, `traces.around` | action | `forEnvironment` → `ownedEnvironment` | `Environment not found` (then sink errors); `overview` with a `nodeId` outside the env → `Node not found` | +| `tracing.forNode` | query | `ownedNode` (+ must be a service with `desired`) | `null` | +| `tracing.enable`, `tracing.localEnv` | action | `internal.tracing.scope` → `requireNode` | `Node not found`; non-service → `Only services can be traced` | +| `tracing.prompt` | query | **none required**: uses `ownedNode` / `ownedEnvironment` only to fill names | generic prompt without names | +| `logSinks.get`, `logSinks.pendingOrgs` | query | `currentMembership` | `null` | +| `logSinks.connectAxiom` | action | `organizations.current` non-null | `NO_ORGANIZATION` | +| `logSinks.disconnect` | mutation | `requireOrganization` | `NO_ORGANIZATION` | +| `logSinks.beginAxiomSignIn` | action | redirect URI validated, Axiom client registered (DCR) **before** any auth check; then `startSignIn` → `requireOrganization` | `NO_ORGANIZATION` (after a possible `axiomClients` insert: Go should check membership first) | +| `logSinks.signInAxiom` | action | `takeSignIn` returns null unless the `state` row's org = caller's org | `Axiom sign-in expired, try again` | +| `logSinks.chooseAxiomOrg` | action | `takePending` (caller's org) | `Sign-in expired, sign in with Axiom again` | +| `logSinks.cancelAxiomSignIn` | mutation | `currentMembership` | silent no-op | + +Role is never checked by any Keel function: `owner` and `member` have identical Keel powers +(including Disconnect Axiom and deleting services). Only Better Auth's invite endpoint looks at +roles. + +--- + +## 10. HTTP router (`convex/http.ts`) + +All routes on `CONVEX_SITE_URL` (port 3211). Matching is exact path + method; unmatched → Convex +404. + +| Method, path | Auth | Request | Responses | +| --- | --- | --- | --- | +| `GET`,`POST` `/api/auth/*`, `OPTIONS /api/auth/*` | per endpoint (§5) | per endpoint | per endpoint; CORS per §4.6 | +| `GET /.well-known/openid-configuration` | none | — | `302` → `${CONVEX_SITE_URL}/api/auth/convex/.well-known/openid-configuration` | +| `POST /worker/events` | worker bearer | Docker events: one JSON object, a JSON array (body starts with `[` after trim), or NDJSON (blank lines skipped). Header `X-Keel-Resync: 1` on the first batch after the worker (re)starts. | `401 "unauthorized"`; `413 "too large"` if body length (JS string length, i.e. UTF-16 units) `> 262144`; `400 "bad json"` if any line/array fails to parse or any element is not an object with `Type` and `Action`; else runs `internal.events.ingest({events, resync})` → `200 "ok"`. Plain-text bodies. | +| `GET /worker/config` | worker bearer | — | `401 "unauthorized"`; `200` JSON of `internal.worker.config` with `content-type: application/json`, `cache-control: no-store`. | +| `POST /proxy/events` | worker bearer | JSON `{event: "cert_obtained"\|"cert_failed", name: string, error?: string}` | `401`; `413 "too large"` (> 262144); `400 "bad json"`; `400 "bad report"` if `event` not one of the two or `name` not a string; else `internal.proxyInternal.certReport({event, name, error: string or undefined})` → `200 "ok"`. | +| `POST /otlp/v1/traces` | `Authorization: Bearer keel_otlp_<…>` (environment ingest key) | OTLP/HTTP, `application/x-protobuf` or `application/json` | `401 "unauthorized"` (no `Bearer `, key without prefix `keel_otlp_`, unknown key, or project without org); `415 "OTLP over HTTP: application/x-protobuf or application/json"`; `413 "too large"` (`Content-Length` or body > 4 MiB); no traces dataset → `200` empty (`""` protobuf / `{}` JSON); else relays to Axiom (details in the traces spec). | + +**Worker bearer** (`authorized(req)`): + +```ts +expected = env.KEEL_WORKER_TOKEN +header = req.headers.authorization ?? "" +if (!expected || !header.startsWith("Bearer ")) return false // case-sensitive "Bearer " +return timingSafeEqual(header.slice(7).trim(), expected) // byte-wise XOR over max length, + // length difference folded in +``` + +**Event trimming** (`trim(e)`) keeps only: +`{ type: String(e.Type), action: String(e.Action), name: e.Actor?.Attributes?.name if string, +serviceName: e.Actor?.Attributes["com.docker.swarm.service.name"] if string, time: e.time if +number }`. Everything else is dropped before `events.ingest`. + +Callers: the worker (`apps/worker/src/controlPlane.ts`) sends `authorization: Bearer `, +`content-type: application/json`, `x-keel-resync: 1|0`, 10 s timeout, retries 5xx/network forever +with backoff, skips 4xx. keel-proxy's `keel` event handler posts `/proxy/events` with +`Authorization: Bearer `, up to 4 attempts, accepts any `< 300`. `install.sh` verifies the +token with `GET /worker/config` (expects 2xx). + +Go (ARCHITECTURE): the same four raw routes keep their paths, auth, size limits, status codes +and plain-text bodies so deployed workers and proxies keep working across the switch. `/api/auth/*` +is replaced by the Go auth API (§13); `/.well-known/openid-configuration` and `/api/auth/convex/*` +are dropped. + +--- + +## 11. Web auth usage (what the UI depends on) + +| Place | Calls | Behaviour | +| --- | --- | --- | +| `lib/config.ts` | `window.__KEEL__` from `/config.js`, else Vite env | Throws "`` is not configured (window.__KEEL__ or .env)" when missing. | +| `main.tsx` | `ConvexBetterAuthProvider` | `isAuthenticated` = session present or cached JWT; JWT fetched via `/convex/token`; handles `?ott=` (OAuth, unused). | +| `routes/index.tsx` `/` | `` → `projects.ensureDefault` → navigate `/p/`; error → message + "Sign out"; `` → `AuthForms` | Bootstrap/founding entry point. | +| `routes/_auth/route.tsx` | gate for `/p/*`, `/device`, `/axiom/callback` | Signed out: `AuthForms` in place, URL kept (so `/device?user_code=` survives sign-in). | +| `components/auth-forms.tsx` | `auth.signUpOpen` | Sign-up only while open; default view sign-up when open, else sign-in; sign-in offers "Need an account? Sign up" only while open, else "Need an account? Ask a member for an invite link." | +| `sign-in-form.tsx` | `signIn.email({email, password})` | Client validation: email format "Invalid email address", password ≥ 8 "Password must be at least 8 characters". Toasts `error.message || statusText`; success toast "Sign in successful". | +| `sign-up-form.tsx` | `signUp.email({email, password, name, invitationId?})` | Client validation: name ≥ 2 "Name must be at least 2 characters", email, password ≥ 8. Success toast "Sign up successful". Titles "Lay the keel" / "Join ". | +| `account-menu.tsx` | `auth.getCurrentUser`, `organizations.current`, `signOut()` | Shows name, email, " · "; "Invite people…" when an org exists. | +| `invite-dialog.tsx` | `organization.inviteMember({email, role: "member", organizationId})` | Link = `${location.origin}/invite/${id}`. Errors toast `error.message ?? "Could not create the invite"`. | +| `routes/invite.$invitationId.tsx` | `organizations.invitation`, `getCurrentUser`, `organization.acceptInvitation` | §7.3. | +| `routes/_auth/device.tsx` | `getCurrentUser`, `GET /device`, `POST /device/approve|deny`, `signOut()` | Code input normalised: remove spaces and `-`, upper-case. On mount `GET /device?user_code=` (binds); error → "Link no longer valid". Shows "Sign in the keel CLI", the code as `ABCD-EFGH`, Approve/Deny; result screens "CLI signed in" / "Sign-in denied". Errors toast `error_description ?? message ?? statusText ?? "Something went wrong"`. | + +--- + +## 12. Realtime (subscriptions in this area) + +Reactive queries the web subscribes to (Convex `useQuery`) here, and the writes that must refresh +them. Coarse keys proposed for the Go WebSocket (`docs/go/ARCHITECTURE.md` publishes path-prefix +topics on a server-chosen channel; add a per-user channel because a signed-in user may have no org). + +| Query (Go endpoint suggestion) | Read set | Invalidate on | Channel / topic | +| --- | --- | --- | --- | +| `auth.getCurrentUser` (`GET /api/me`) | session row (exists, unexpired), user row | sign-out / session deleted or expired → push and **close the socket** (client falls back to signed out); user updated | `session:`, `user:`; topic `/api/me` | +| `organizations.current` (`GET /api/me` or `/api/organization`) | caller's member row, organization row | member inserted for the user (founding, sign-up-with-invite, accept), member deleted/role changed, org renamed | `user:` and `org:`; topic `/api/me`, `/api/organization` | +| `auth.signUpOpen` (`GET /api/auth/sign-up-open`, public) | existence of any user | first user inserted | no socket when signed out: refetch on window focus and after a 403 sign-up; optional public channel `install` | +| `organizations.invitation` (`GET /api/invitations/{id}`, public) | invitation row, organization row | invitation status/expiry change | refetch on focus; `invitation:` if a socket exists | +| everything org-scoped (projects, nodes, sinks, …) | via membership | membership created → the user's whole view changes | on member insert publish "invalidate all" to `user:` and move the connection onto `org:` (server-side subscription) | + +Convex queries that use `Date.now()` (expiry checks) are not re-run as time passes; Go need not +push on expiry either (except closing sockets of expired sessions lazily on next use). + +--- + +## 13. Go port: auth API recommendation + +Everything below is a recommendation consistent with `docs/go/ARCHITECTURE.md` (Huma, `/api`, +problem+json with `code`, cookie `keel_session` HttpOnly SameSite=Lax Secure-on-https, or +`Authorization: Bearer `). Paths and names are the API spec owner's call; behaviour +must match §5–§9. + +| Operation | Suggested route | Behaviour to preserve | +| --- | --- | --- | +| `getSignUpOpen` | `GET /api/auth/sign-up-open` → `{open}` | §6.2, public | +| `signUp` | `POST /api/auth/sign-up {email, password, name, invitationId?}` | §5.1 order and messages, §6.1 hooks, one transaction; sets cookie; returns `{user, token}` | +| `signIn` | `POST /api/auth/sign-in {email, password}` | §5.2; 401 "Invalid email or password"; sets cookie; returns `{user, token}` | +| `signOut` | `POST /api/auth/sign-out` | delete presented session; always 200 `{success: true}`; clear cookie; close its sockets | +| `getSession` | `GET /api/auth/session` | `{user, session}` or 401 | +| `getMe` | `GET /api/me` → `{user: {id, email, name}, organization: {id, name, slug, role} \| null}` | merges §7.7 and §7.5 (CLI `whoami` shape) | +| `getInvitation` | `GET /api/invitations/{id}` (public) → `{email, organization}` | §7.6; 404 instead of null | +| `createInvitation` | `POST /api/organization/invitations {email, role?}` | §7.2 rules and messages (owner/admin only, cancel previous pending, limit 100, 7-day TTL); return `{id, email, role, expiresAt}`; the web builds the link | +| `acceptInvitation` | `POST /api/invitations/{id}/accept` | §7.4 intended behaviour (no email-verified gate) | +| `ensureDefaultProject` | `POST /api/projects/default` → `{slug}` | §6.3 (founding + legacy adoption + default project) | +| Device flow | `POST /api/auth/device/code`, `POST /api/auth/device/token`, `GET /api/auth/device?user_code=`, `POST /api/auth/device/approve`, `POST /api/auth/device/deny` | §5.3 exactly, RFC 8628 bodies (`error`/`error_description`, `slow_down` rule, single-use token, binding on GET, approve/deny only by the bound user). `verification_uri` = `KEEL_SITE_URL + "/device"`. `client_id` must be `keel-cli`. | + +Session tokens: keep 32-char `[a-zA-Z0-9]` tokens (or longer), 7-day expiry with 1-day rolling +refresh, lookup by token. Accept `Bearer ` with or without the legacy `.` +suffix (strip it) so tokens printed by `keel token` before the switch keep working if sessions are +imported. Password hashes import unchanged (§4.5). Member/organization/invitation rows import +unchanged (ids are opaque strings). Device codes and JWKS need no import. + +CLI (Go, same binary): drop `/convex/token` and the Convex HTTP API; call `/api/*` with +`Authorization: Bearer `; keep the device flow, config file format, `KEEL_URL` / +`KEEL_TOKEN`, pending-login semantics (§8.2), output codes and exit codes unchanged. Map +server `code` directly; `Message` strings stay identical to the Convex ones listed in this file. + +Known current defects the port should fix rather than copy (each listed above): +1. `accept-invitation` always 403s (email-verification gate) — §7.4. +2. Members see "Invite people…" but cannot invite (403) — §7.2 step 4. Either hide it for + `member` or allow members to invite (product call; today's server rule is owner/admin only). +3. Sign-up user/account/member/session writes are not atomic — §5.1. +4. `beginAxiomSignIn` registers an Axiom OAuth client before checking membership — §9.3. +5. Founding is lazy and can be skipped entirely by the device-login path — §6.4. diff --git a/docs/go/spec/cli-install.md b/docs/go/spec/cli-install.md new file mode 100644 index 0000000..309432e --- /dev/null +++ b/docs/go/spec/cli-install.md @@ -0,0 +1,1145 @@ +# Porting spec: the `keel` CLI, install, deploy, CI + +Scope: everything outside the Convex functions and the web UI that a person or an agent touches to +get Keel running and to drive it from a terminal. Written so the Go rewrite (one binary: `keel +serve` control plane with embedded web, SQLite and Caddy; `keel agent` per node; the CLI) can +reproduce today's behaviour without reading the TypeScript, shell or YAML. + +Sources read (at `36c2ded`, branch `go-single-binary`): + +- `apps/cli/**` (Go module `github.com/ThallesP/keel/apps/cli`, every `.go` file and `README.md`) +- `install.sh`, `README.md` (Install, Install with an agent, Development) +- `deploy/compose.yml`, `deploy/functions-entrypoint.sh`, `deploy/functions.Dockerfile` +- `scripts/bootstrap-swarm.sh`, `scripts/deploy-worker.sh`, `scripts/dev-https.sh` +- `apps/web/{Dockerfile,docker-entrypoint.sh,nginx.conf,public/config.js,src/lib/config.ts,.env.schema}`, + `apps/worker/Dockerfile`, `apps/proxy/{Dockerfile,caddy.json}` +- `.github/workflows/{ci.yml,images.yml,pullfrog.yml}`, root `package.json`, `turbo.json`, `bunfig.toml`, `.dockerignore` +- Server side of every call the CLI makes: `convex/{auth,access,organizations,projects,environments,nodes,nodeHelpers,status,variables,deployments,logs,traces,timeRange,tracing,tracingPrompt,http,worker,migrations}.ts` +- better-auth 1.6.17 `plugins/device-authorization/{index,routes,error-codes,schema}.mjs`, `@convex-dev/better-auth` 0.12.5 `plugins/convex` + +Part A documents what exists. Part B says what each piece becomes in the single-binary world. +Where Part B proposes routes or names, other specs in `docs/go/spec/` that define the HTTP API are +authoritative for the route; the semantics listed here are what the CLI needs. + +--- + +# Part A — what exists today + +## A1. The CLI at a glance + +| Item | Value | +| --- | --- | +| Module | `github.com/ThallesP/keel/apps/cli`, `go 1.27.1`, own `go.mod` outside bun/turbo | +| Deps | `github.com/spf13/cobra v1.10.2`, `golang.org/x/term v0.46.0` (indirect: pflag, mousetrap, x/sys) | +| Entry | `cmd/keel/main.go`: `keel.UserAgent = "keel-cli/" + version`; `ctx := signal.NotifyContext(Background, os.Interrupt, SIGTERM)`; `os.Exit(cli.Execute(ctx, version))` | +| Version | `var version = "dev"`, set with `-ldflags "-X main.version=1.2.3"`. Cobra's `--version` prints `keel version ` (plain text, not the envelope) | +| Build | `cd apps/cli && go build -o bin/keel ./cmd/keel` (`/bin/` gitignored). No release pipeline: there is no published CLI binary today | +| Packages | `internal/output` (contract), `internal/config` (config.json), `internal/convex` (Convex HTTP API client), `internal/keel` (discovery, sign-in, typed calls, `translate`), `internal/cli` (one file per noun) | +| Transport | Plain HTTP. No websockets, no subscriptions: everything that watches, polls | +| HTTP client | One shared `http.Client{Timeout: 60s}`; every request sets `User-Agent: keel-cli/`; JSON bodies set `Content-Type: application/json` | + +## A2. Output contract (`internal/output`) — must be preserved byte-for-byte + +"Fields and codes are only ever added, never renamed or removed." + +- **stdout = results only.** Human mode: text/tables. JSON mode (`--json`, or `KEEL_JSON=1` or + `KEEL_JSON=true`): exactly one JSON object + `\n`. + - `Result(v)`: encodes `v` (must encode to an object) with `json.Encoder`, `SetEscapeHTML(false)` + (so `<`, `>`, `&` stay literal), then rewrites it so `"ok"` is the first key: + `{"ok":true` + (`,` unless the object was empty) + rest. `struct{}{}` → `{"ok":true}`. + - Streams (`logs --follow`): one JSON object per line via `Event(v)`, **without** `ok`. + - `keel run` leaves stdout to the child process. +- **stderr** = progress (`Progress`: `format + "\n"`), warnings (`"warning: " + format + "\n"`), + and errors. +- **Errors** (`Fail(e)`): always writes to stderr `error: \n` and, if `Fix != ""`, + `fix: \n` (three spaces after `fix:`). In JSON mode additionally writes to stdout + `{"ok":false,"code":…,"error":…,"fix":…}` plus every key of `e.Extra`. The body is a Go map, so + after `ok` **all** keys are in sorted order, extras interleaved: e.g. + `{"ok":false,"code":…,"deployment":…,"done":…,"error":…,"fix":…}`. `fix` is always present + (possibly `""`). +- **Exit codes**: 0 ok; 1 any error code not listed; 2 `USAGE`; 4 `NOT_AUTHENTICATED` and + `AUTHORIZATION_PENDING`; 130 `CANCELLED`. `keel run` exits with the child's code, `128+signal` + when the child was killed by a signal. `logs -f` exits 0 on Ctrl-C. +- **No prompts without a terminal**: only `service delete` ever prompts, and only when + `!JSON && isatty(stdin)`. +- **Times** print as RFC 3339 UTC with milliseconds: `keel.Time.MarshalJSON` uses + `"2006-01-02T15:04:05.000Z07:00"` on `t.UTC()` → `2026-10-08T12:00:00.123Z`. Exception: + `login`'s `expiresAt` is a plain `time.Time` truncated to seconds, UTC → `2026-10-08T12:30:00Z`. + +### Error codes (stable, agents branch on them) + +| Code | Exit | Raised when | +| --- | --- | --- | +| `USAGE` | 2 | Bad flags/args, cobra errors (unknown command, arg count), missing required input; `fix` = ` --help` unless stated | +| `NOT_AUTHENTICATED` | 4 | No saved login, expired/revoked session, server says `Not authenticated`, HTTP 401, device code expired/denied/used up | +| `AUTHORIZATION_PENDING` | 4 | A pending `keel login` link not yet approved | +| `NO_ORGANIZATION` | 1 | Server message starts with `You're not in an organization` | +| `NO_PROJECTS` | 1 | `projects:list` returned `[]` | +| `PROJECT_REQUIRED` | 1 | Several projects, none picked | +| `PROJECT_NOT_FOUND` | 1 | Unknown slug/id, project without environment, `Environment not found`, `environments:summary` null | +| `SERVICE_NOT_FOUND` | 1 | Name/id not in `nodes:list`, server `Node not found`, created service vanished | +| `VARIABLE_NOT_FOUND` | 1 | `var delete` of a key the service lacks | +| `DEPLOYMENT_NOT_FOUND` | 1 | `deployments:get`/`latest` returned null | +| `DEPLOYMENT_RUNNING` | 1 | Server `A deployment is already running` | +| `DEPLOYMENT_FAILED` | 1 | Awaited deployment ended `failed`; adds `"deployment"` to the error object | +| `NOTHING_TO_SHIP` | 1 | Server `Nothing to ship` | +| `NAME_TAKEN` | 1 | Server `Project "" already exists` or `"" is already taken` | +| `TRACES_OFF` | 1 | Server `Connect Axiom to see traces` or `Sign in with Axiom again to turn on traces` | +| `INVALID_INPUT` | 1 | Any other server `ConvexError` string; client-side "only services can be traced" | +| `DISCOVERY_FAILED` | 1 | `/config.js` missing, malformed or without URLs | +| `NETWORK_ERROR` | 1 | Transport error that is a `net.Error` but not a timeout | +| `SERVER_ERROR` | 1 | Non-ConvexError function failure, non-JSON responses, unexpected auth responses | +| `CONFIG_ERROR` | 1 | Can't read/parse/write `config.json` | +| `TIMEOUT` | 1 | `net.Error` timeout; `--timeout` elapsed while awaiting a deployment | +| `CANCELLED` | 130 | Ctrl-C/SIGTERM, `context.Canceled`, declined delete prompt | + +### `Execute` (root.go) error funnel + +1. `cmd.ExecuteContextC(ctx)`; nil → exit 0. +2. `*childExit` (from `run`) → its code, nothing printed. +3. If the printer was never created (failure before `PersistentPreRun`: bad flag, unknown + command), create it with JSON mode = `--json`/`KEEL_JSON` **or** `jsonArg(os.Args[1:])`. + `jsonArg` scans raw args up to `--`: `--json` → true, `--json=` → `strconv.ParseBool(v)` + (invalid → false); last one wins. +4. `*output.Error` → as is; else if `ctx.Err() != nil` → `CANCELLED "Cancelled"`; else (cobra's + own errors) → `USAGE ""`, fix ` --help`. +5. `Fail(e)` → exit code. + +Flag parse errors go through `SetFlagErrorFunc` → `USAGE`. `args(min,max)` validators give +`USAGE "usage: "`. `SilenceUsage`/`SilenceErrors` are on; the completion command +is hidden. + +## A3. Global flags and environment + +| Flag / env | Meaning | +| --- | --- | +| `--json` / `KEEL_JSON=1` (or `true`) | JSON mode (same switch install.sh reads) | +| `--instance ` / `KEEL_INSTANCE` | Which saved install | +| `-p, --project ` / `KEEL_PROJECT` | Which project | +| `KEEL_URL` | Dashboard URL; use an install without a saved login (with `KEEL_TOKEN`) | +| `KEEL_TOKEN` | Session token; overrides the saved one | +| `KEEL_CONVEX_URL`, `KEEL_CONVEX_SITE_URL` | With `KEEL_URL`: skip discovery (dev servers whose `/config.js` is empty) | +| `KEEL_CONFIG_DIR` | Config directory (default below) | +| `XDG_CONFIG_HOME` | Fallback base for the config dir | + +## A4. Config file (`internal/config`) + +Location: `$KEEL_CONFIG_DIR`, else `$XDG_CONFIG_HOME/keel`, else `~/.config/keel` (all OSes). +File `config.json`. Missing file = empty config. Written atomically: `MkdirAll(dir, 0700)`, +`CreateTemp(dir, ".config-*.json")`, `chmod 0600`, write `MarshalIndent(c, "", " ") + "\n"`, +close, `rename`. Parse error → `CONFIG_ERROR "Can't read the config file: "`, fix +`Fix or delete the file, then keel login again`. Write error → `CONFIG_ERROR "Can't write the +config file: "`, fix `Check the permissions of `. + +```json +{ + "current": "keel.example.ts.net", + "instances": { + "keel.example.ts.net": { + "url": "https://keel.example.ts.net", + "convexUrl": "http://100.64.0.1:3210", + "convexSiteUrl": "http://100.64.0.1:3211", + "email": "me@example.com", + "token": "", + "pending": { + "deviceCode": "<40-char secret>", + "userCode": "ABCDEFGH", + "url": "https://keel.example.ts.net/device?user_code=ABCDEFGH", + "expiresAt": "2026-10-08T12:30:00Z", + "interval": 5 + } + } + }, + "links": { "/home/me/acme": { "instance": "keel.example.ts.net", "project": "acme-api" } } +} +``` + +All of `current`, `instances`, `links`, `email`, `token`, `pending` are `omitempty`. Instance +name defaults to the dashboard URL's host (`host[:port]`). `LinkFor(dir)` walks `dir` then each +parent until `/`; first hit wins. `RemoveInstance(name)` deletes the instance, every link whose +`instance == name`, and clears `current` if it pointed there. `PendingLogin.Expired()` is +`!now.Before(expiresAt)`. + +## A5. Target resolution (`internal/cli/context.go`) + +**Install** (`target`): + +1. `KEEL_URL` set: `normalizeURL` (must parse, scheme `http`/`https`, non-empty host; result is + `scheme://host`, path/query dropped, input trimmed) else `USAGE "KEEL_URL: "` fix + `KEEL_URL=https://`. Instance = `{URL, ConvexURL: $KEEL_CONVEX_URL, + ConvexSiteURL: $KEEL_CONVEX_SITE_URL, Token: $KEEL_TOKEN}`. If a saved instance has the same + URL and `ConvexURL` is still empty, copy **both** Convex URLs from it. If either is still empty + → `Discover`. Name = host. Never written to the file. +2. Else name = `--instance` → `KEEL_INSTANCE` → link of cwd (its `instance`) → `current` → the + only saved instance. Missing instance: if a name was chosen and some instances exist → + `NOT_AUTHENTICATED "Not logged in to an instance named \"\" (known: a, b)"` (sorted), fix + `keel login --name `; else `NOT_AUTHENTICATED "Not logged in"`, fix + `keel login , or set KEEL_URL and KEEL_TOKEN`. +3. `KEEL_TOKEN` set → a copy of the instance with that token (the file is never touched). + +`connect` = load config → `target` → `finishLogin` → `keel.Connect` (JWT exchange, A8). + +**Project** (`projectSlug`): `--project` → `KEEL_PROJECT` → cwd link's project **only if** +`link.instance == session name` → `""`. `pickProject(projects, slug)`: + +| Situation | Result | +| --- | --- | +| `projects == []` | `NO_PROJECTS "No projects yet"`, fix `keel project create --link` | +| a project's `slug == s` or `id == s` | that project | +| `s == ""`, one project | it | +| `s == ""`, several | `PROJECT_REQUIRED " projects and none picked"`, fix `Pass --project or run keel link (projects: a, b)` | +| no match | `PROJECT_NOT_FOUND "No project \"\""`, fix `Projects: a, b` | + +**Environment**: `project.environments[0]` (server sorts production first); none → +`PROJECT_NOT_FOUND "Project has no environment"`, fix `Open and check the project`. +There is no `--environment` flag. + +**Service** (`findService`): first node of `nodes:list` (groups removed) whose `name == x` or +`id == x`; else `SERVICE_NOT_FOUND "No service \"\""`, fix `Services: a, b` (in list order) +or `keel service list` when there are none. + +## A6. Discovery via `/config.js` (`keel.Discover`) + +- `GET /config.js` (UA header only). Transport error → `translate` (network codes). +- Body: take the bytes from the **first `{`** to the **last `}`** inclusive and JSON-decode + `{"convexUrl": string, "convexSiteUrl": string}` (other keys ignored). +- Failure (status ≠ 200, no braces, bad JSON) → `DISCOVERY_FAILED " doesn't look like a Keel + dashboard (no /config.js)"`. Either field empty → `DISCOVERY_FAILED "/config.js has no + Convex URLs (a dev server?)"`. Fix for both: `keel login --convex-url + --convex-site-url `. +- Values are returned with trailing `/` trimmed. + +What the file looks like: + +- Installed: written by `apps/web/docker-entrypoint.sh` at container start: + `window.__KEEL__ = {"convexUrl":"http://100.64.0.1:3210","convexSiteUrl":"http://100.64.0.1:3211"};` + (printf `'window.__KEEL__ = {"convexUrl":"%s","convexSiteUrl":"%s"};\n'`). nginx serves it with + `Cache-Control: no-store`. +- Dev (`apps/web/public/config.js`): `window.__KEEL__ = {};` → discovery fails, by design. +- The web reads `window.__KEEL__?.convexUrl || VITE_CONVEX_URL` (same for site URL) and throws + `" is not configured (window.__KEEL__ or .env)"` when both are empty. + +## A7. Convex HTTP API transport (`internal/convex`) + +Request: `POST /api/{query|mutation|action}`, body +`{"path":"module:function","args":{…},"format":"json"}`; `nil` args are sent as `{}`; +`Authorization: Bearer ` when a token is set. + +Response handling: + +- Decode `{"status","value","errorMessage","errorData"}`. If not JSON or `status == ""`: HTTP 401 → + wraps `ErrUnauthenticated` (`" : unauthenticated: "`), else + `" : HTTP : "` (snippet = trimmed body, cut at 200 chars + `…`). +- `status != "success"` → `FunctionError{Message: clean(errorMessage), Data: errorData}`. + `clean` strips `^\[Request ID: [^\]]*\] (Server Error\n)?`, a leading `Uncaught `, and + everything from `"\n at "` (stack), then trims. `DataString()` = `errorData` when it is a + JSON string (every Keel `ConvexError`), else `""`. +- Success → decode `value` into the out param. + +Numbers arrive as floats: `keel.Int` decodes any JSON number into an int; `keel.Time` decodes +epoch milliseconds (float) into `time.Time` UTC. + +## A8. Authentication (CLI side + the server behaviour it depends on) + +### Endpoints used (all on the Convex **site** URL, prefix `/api/auth`) + +Every auth request carries `Origin: ` (better-auth trusts exactly `SITE_URL`) and, +when given, `Authorization: Bearer `. + +| Call | Request | Success | CLI handling | +| --- | --- | --- | --- | +| `StartLogin` | `POST /api/auth/device/code` `{"client_id":"keel-cli"}` | 200 `{device_code,user_code,verification_uri,verification_uri_complete,expires_in,interval}` | Pending = `{deviceCode, userCode, url: verification_uri_complete, expiresAt: now+expires_in (UTC, truncated to s), interval: max(interval,1)}` | +| `PollLogin` | `POST /api/auth/device/token` `{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":…,"client_id":"keel-cli"}` | 200 `{access_token,token_type:"Bearer",expires_in,scope}` | see table below | +| `SignOut` | `POST /api/auth/sign-out` `{}` + bearer | 200 | failure → warning only | +| `Connect` | `GET /api/auth/convex/token` + bearer | 200 `{"token": ""}` | JWT (15 min, audience `convex`) used as the Convex bearer for this run; never cached | + +`PollLogin` outcome mapping (`json.Unmarshal` of `{access_token,error,error_description}`): + +| Response | Result | +| --- | --- | +| 200 and `access_token != ""` | token | +| `error == "authorization_pending"` | no token, no error | +| `error == "slow_down"` | no token, `slowDown = true` | +| `expired_token` | `NOT_AUTHENTICATED "The login link expired before anyone approved it"` | +| `access_denied` | `NOT_AUTHENTICATED "The login was denied in the dashboard"` | +| `invalid_grant` | `NOT_AUTHENTICATED "The login link is used up or unknown"` | +| other with `error_description` | `SERVER_ERROR "/api/auth/device/token: "` | +| otherwise | `SERVER_ERROR "/api/auth/device/token: HTTP "` | + +`authCall` (code, sign-out, convex/token): 200 → decode (bad JSON → `SERVER_ERROR "unexpected +response from : "`); 401 → `NOT_AUTHENTICATED "Session expired or signed out"`; +body `message` or `error_description` → `SERVER_ERROR ": "`; else `SERVER_ERROR +": HTTP "`. `notAuthenticated(url,msg)` fix = `keel login ` (or +`keel login , or set KEEL_URL and KEEL_TOKEN` with no URL). + +### Server behaviour (better-auth `deviceAuthorization`, configured in `convex/auth.ts`) + +Config: `verificationUri: SITE_URL + "/device"`, `validateClient: id === "keel-cli"`, defaults +`expiresIn 30m`, `interval 5s`, `deviceCodeLength 40` (chars `a-zA-Z0-9`), `userCodeLength 8` +(each char = `"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[randomByte % 32]`). + +Table `deviceCode`: `id, deviceCode, userCode, userId?, expiresAt, status ("pending"|"approved"|"denied"), lastPolledAt?, pollingInterval? (ms), clientId?, scope?`. + +`POST /device/code` — client ≠ `keel-cli` → 400 `{"error":"invalid_client","error_description":"Invalid client ID"}`. Inserts `{status:"pending", userId:null, expiresAt: now+30m, pollingInterval: 5000, clientId, scope}`. Returns `expires_in: 1800`, `interval: 5`, `verification_uri: /device`, `verification_uri_complete: /device?user_code=`; header `Cache-Control: no-store`. + +`POST /device/token` — evaluated **in this order** (all errors HTTP 400 `{"error","error_description"}` unless noted): + +1. client ≠ `keel-cli` → `invalid_grant` / `Invalid client ID` +2. no row for `device_code` → `invalid_grant` / `Invalid device code` +3. row `clientId` set and ≠ → `invalid_grant` / `Client ID mismatch` +4. `lastPolledAt` and `now - lastPolledAt < pollingInterval` → `slow_down` / `Polling too frequently` (**`lastPolledAt` is not updated**) +5. `lastPolledAt = now` +6. `expiresAt < now` → delete row; `expired_token` / `Device code has expired` +7. `pending` → `authorization_pending` / `Authorization pending` +8. `denied` → delete row; `access_denied` / `Access denied` +9. `approved` with `userId` → atomically consume (delete where `deviceCode` and `status = approved`); nothing consumed → `invalid_grant` / `Invalid device code`; user missing → 500 `server_error` / `User not found`; create a session (session-create hook sets `activeOrganizationId` = the user's membership org or null); 200 `{access_token: session.token, token_type: "Bearer", expires_in: , scope: ""}` with `Cache-Control: no-store`, `Pragma: no-cache`. The token goes to **one** poll only. +10. anything else → 500 `server_error` / `Invalid device code status` + +`GET /device?user_code=X` (dashboard `/device` page; CI uses it) — strips `-` only; no row → 400 `invalid_request` / `Invalid user code`; expired → 400 `expired_token` / `User code has expired`; if a session is present and the row is `pending` with no `userId`, binds `userId` (conditional update `where status=pending and userId is null`). Returns `{"user_code": , "status": }`. + +`POST /device/approve {userCode}` and `POST /device/deny {userCode}` — no session → 401 `unauthorized` / `Authentication required`; strip `-`; no row → 400 `invalid_request` / `Invalid user code`; expired → 400 `expired_token` / `User code has expired`; status ≠ pending → 400 `invalid_request` / `Device code already processed`; `userId` null → 400 `invalid_request` / ``Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying``; `userId` ≠ caller → 403 `access_denied` / `You are not authorized to approve this device authorization` (deny: `…to deny this…`). Sets `status` approved/denied. 200 `{"success":true}`. + +Sessions: better-auth defaults — token is a random string stored in the `session` table, lifetime +7 days, renewed (expiry pushed out) when used more than 1 day after the last refresh. The bearer +plugin accepts `Authorization: Bearer `. `POST /sign-out` deletes the session. + +Also used by CI (not the CLI): `POST /api/auth/sign-up/email {email,password,name[,invitationId]}` +→ `{token, user:{id,…}}` (first account free; later ones need a pending invitation for that +email, else 403 `Sign-up is by invitation. Ask a member for an invite link.`), and +`POST /api/auth/sign-in/email {email,password}` → `{token, user, …}`. + +### `keel login` algorithm (auth.go) + +1. `--wait` and `--no-wait` together → `USAGE "--wait and --no-wait are exclusive"`. +2. `loginTarget`: + - No URL argument: `KEEL_URL` set or no saved instances → `USAGE "pass the dashboard URL: keel login "`. Else name from `target` (only the name is used: the instance is the **saved** one, never the `KEEL_TOKEN` copy). + - URL argument: `normalizeURL` (error → `USAGE "\"\" is not a dashboard URL (http:// or https:// and a host)"`). Name = `--name` or host. Reuse the saved instance only if same name **and** same URL; else a fresh `{URL}`. + - Convex URLs: `--convex-url`/`--convex-site-url` override saved values; if either is empty → `Discover` fills the missing one(s); trailing `/` trimmed. +3. `SetInstance(name, inst)`, `current = name`. +4. Instance has a token → `Connect`: `NOT_AUTHENTICATED` → drop the token and continue; other error → fail; success → `loggedIn` (prints `status: "loggedIn"`, saves `email` and `current`; nothing else changes). +5. Pending link not expired → one `PollLogin`: `NOT_AUTHENTICATED` → drop pending; other error → fail; token → step 9. `slow_down` is ignored here. +6. No token and (no pending or expired) → `StartLogin` (new link). +7. Save config. +8. Still no token: + - `!--wait && (--no-wait || JSON || stdin not a TTY)` → print pending result, exit 0: + `{"ok":true,"status":"pending","instance":…,"url":…,"approvalUrl":,"code":"ABCD-EFGH","expiresAt":…,"next":"Send approvalUrl to a person to approve, then carry on: the next keel command finishes the login (or wait for it: keel login --wait)"}`. + Human: `To log in to , open this link and approve (if you are an agent, send it to your human):\n\n \n\nCode ABCD-EFGH, valid until HH:MM. The next keel command after the approval finishes the login; keel login --wait waits for it.` + - Else stderr progress `Open this link and approve to log in to :\n\n \n\nCode ABCD-EFGH. Waiting for the approval (Ctrl-C stops waiting; the link stays valid until HH:MM)…`, then `waitForApproval`: sleep `interval`, poll; error or token → return; `slow_down` → `interval += 5s`; ctx done → `CANCELLED "Stopped waiting"`, fix `keel login --wait (the link stays valid)`. +9. Save the token and clear pending **before** anything else can fail (the token is handed out once), then `Connect` and `loggedIn`. + +`prettyCode`: 8-char codes print as `ABCD-EFGH`. `loggedIn` warns `this account isn't in the install's organization yet; projects stay empty until a member invites it` when `organizations:current` is null. + +### `finishLogin` (runs at the start of every signed-in command and `token`) + +- Nothing to do if the instance has a token (or `KEEL_TOKEN`) or no pending link. +- Pending expired → `NOT_AUTHENTICATED "The login link expired before anyone approved it"`, fix `keel login ` (no poll). +- `PollLogin`; on `slow_down` wait `pending.interval` seconds (ctx-cancellable → `CANCELLED "Cancelled"`) and poll once more. +- Poll error → reload the config from disk: if the fresh instance (same name, same URL) now has a token, another concurrent `keel` run took it → adopt it, success. Else if the error is `NOT_AUTHENTICATED` and the saved pending device code is still this one → delete `pending`, save (later runs say "Not logged in" instead of polling a dead code). Return the error. +- No token → `AUTHORIZATION_PENDING "Waiting for someone to approve the login"`, fix `Open and approve (agents: send it to your human), then retry; or keel login --wait`. +- Token → save it, clear pending, stderr `Login approved; saved for `, continue the command. + +## A9. `translate()` — Convex errors → CLI errors (`internal/keel/api.go`) + +Applied to every Convex call and to transport errors from discovery/auth. Order matters. + +| # | Input | Code | Message | Fix | +| --- | --- | --- | --- | --- | +| 1 | already `*output.Error` | as is | | | +| 2 | ConvexError `"Not authenticated"` | `NOT_AUTHENTICATED` | `Session expired or signed out` | `keel login ` | +| 3 | starts with `You're not in an organization` (full server text: `You're not in an organization yet. Ask a member for an invite link.`) | `NO_ORGANIZATION` | `This account isn't in the install's organization` | `Ask a member for an invite link (account menu → Invite people), then keel login ` | +| 4 | `"A deployment is already running"` | `DEPLOYMENT_RUNNING` | `A deployment is already running in this environment` | `keel deployment get --wait` | +| 5 | `"Nothing to ship"` | `NOTHING_TO_SHIP` | `Nothing to ship: no service has staged changes` | `Stage a change first (keel var set …), or redeploy: keel redeploy ` | +| 6 | `"Node not found"` | `SERVICE_NOT_FOUND` | `Service not found` | `keel service list` | +| 7 | `"Connect Axiom to see traces"` or `"Sign in with Axiom again to turn on traces"` | `TRACES_OFF` | the server message | `Open Observability in the dashboard () and Sign in with Axiom` | +| 8 | `"Environment not found"` | `PROJECT_NOT_FOUND` | `Environment not found` | `keel project list` | +| 9 | prefix `Project "` and suffix `" already exists` | `NAME_TAKEN` | the server message | `Pick another name, or use it: keel link ` | +| 10 | suffix `" is already taken` | `NAME_TAKEN` | the server message | `Pick another name; keel service list shows the taken ones` | +| 11 | any other non-empty ConvexError string | `INVALID_INPUT` | the server message | `""` | +| 12 | FunctionError without string data (plain `throw new Error`, validator errors) | `SERVER_ERROR` | cleaned `errorMessage` | `""` | +| 13 | `ErrUnauthenticated` (HTTP 401 without envelope) | `NOT_AUTHENTICATED` | `Session expired or signed out` | `keel login ` | +| 14 | `context.Canceled` | `CANCELLED` | `Cancelled` | `""` | +| 15 | `net.Error` with `Timeout()` | `TIMEOUT` | `Timed out talking to ` | `Retry; check that is up` | +| 16 | other `net.Error` (every `*url.Error`) | `NETWORK_ERROR` | `Can't reach : ` | `Check the URL, and that this machine is on the install's tailnet` | +| 17 | anything else | `SERVER_ERROR` | `%v` | `""` | + +`` is the dashboard URL; `` is its host (falls back to the whole URL). + +ConvexError strings the CLI can receive from the functions it calls (every one not in rows 2–10 +surfaces as `INVALID_INPUT` with the message verbatim, so the Go server must keep the exact +text): + +- `Project name: 1–60 characters`, `Project name needs a letter or digit (a-z, 0-9)` (projects:create) +- `Only services take a custom image`, `This node type has no runtime settings`, ` is not a `, `Image must look like repo/name:tag`, `Name: 1–40 chars, a-z 0-9 and - only`, `Port must be 1–65535`, `Replicas must be 0–20` (nodes:create) +- `Key: UPPER_SNAKE_CASE only`, `Value too long`, ` already exists` (variables:set) +- `Only services can be traced` (tracing:enable, tracing:localEnv) +- Axiom failures in traces:overview are wrapped as `ConvexError(err.message)` (e.g. `Axiom 403: …`) → `INVALID_INPUT`. logs:tail provider failures are plain `Error`s → `SERVER_ERROR`. + +## A10. Commands + +Conventions: "connect" = A5 `connect`; "project" = resolve project + environment; "service" = +resolve via `nodes:list`. Shapes below are the JSON **after** `"ok":true`. `Service`, `Deployment` +etc. are defined in A11. + +| Command | Args / flags | Pre-connect validation | Calls (in order) | JSON result | +| --- | --- | --- | --- | --- | +| `login [dashboard-url]` | `--wait`, `--no-wait`, `--name`, `--convex-url`, `--convex-site-url` | A8 | discovery, device code/token, convex/token, `auth:getCurrentUser`, `organizations:current` | pending or `{"status":"loggedIn","instance","url","user":User,"organization":Org|null}` | +| `logout` | — | `KEEL_URL` set → `USAGE "KEEL_URL is set; logout only removes saved logins (unset KEEL_URL and KEEL_TOKEN instead)"` | `target` (no finishLogin); if token: `POST /api/auth/sign-out` (failure → `warning: couldn't revoke the session on the server (); forgetting it here anyway`) | `{"instance"}` after `RemoveInstance` + save | +| `whoami` | — | | connect, `auth:getCurrentUser`, `organizations:current` | `{"instance","url","user":User,"organization":Org|null}` | +| `token` | — | | `target`, `finishLogin` (no Connect); no token → `NOT_AUTHENTICATED "Not logged in"` fix `keel login ` | `{"token"}` (human: the token alone) | +| `project list` (`projects`, `ls`) | — | | connect, `projects:list` | `{"projects":[{id,name,slug,environments,current:bool}]}`; `current` = matches `projectSlug`, or the only project | +| `project create ` | `--link` | 1 arg | connect, `projects:create {name}`; with `--link` save link `cwd → {session name, slug}` (save failure → same CONFIG_ERROR with message prefixed `Created project , but can't link it: `) | `{"project":Project,"url":"/p/","linked":""?}` | +| `link [project]` | uses `--project` when no arg (not `KEEL_PROJECT`, not existing link) | 0–1 | connect, `projects:list`, `pickProject` | `{"dir","instance","project":{id,slug,name}}` | +| `unlink` | — | | config only (no network) | `{"unlinked":bool,"dir"?}` | +| `status` | — | | connect, `projects:list`, `environments:summary`, `nodes:list`, `deployments:latest` (its `log` dropped) | `{"instance","url","project":{id,slug,name},"environment":Environment,"pendingChanges","servers","services":[Service],"latestDeployment":Deployment|null}` | +| `service list` (`services`, `ls`) | — | | connect, project, `nodes:list` | `{"services":[Service]}` | +| `service create ` | `--image` (required), `--port N`, `--replicas N` (sent only when the flag was given) | `--image` missing → `USAGE "--image is required: the image to run, e.g. --image nginx:alpine"`; non-int → flag USAGE | connect, project, `nodes:create {environmentId,type:"service",name,image,port?,replicas?}`, then `nodes:list` to find the returned id (gone → `SERVICE_NOT_FOUND "Service was deleted right after it was created"`, fix `keel service list`) | `{"project":{id,slug,name},"service":Service}` | +| `service delete ` (`rm`) | `-y/--yes` | no `--yes` and not interactive → `USAGE "Deleting can't be undone: pass --yes to confirm"` | connect, project, service, prompt `Delete ? It stops now and its variables are dropped. [y/N] ` (only `y`/`yes`, case-insensitive; else `CANCELLED "Nothing deleted"`), `nodes:remove {id}` | `{"service","id","deleted":true}` | +| `logs ` | `-n/--lines` (default 100, 1–1000), `-f/--follow` | lines out of range → `USAGE "--lines must be 1–1000"` | connect, project, service, `logs:tail {nodeId, tail: n}` | `{"service","source","lines":[LogLine]}` | +| `traces [service]` | `--since 15m|1h|24h|7d` (default 15m), `--search s` | bad since → `USAGE "--since must be one of 15m, 1h, 24h, 7d"` | connect, project, service?, `traces:overview {environmentId, range, nodeId?, search?}` | `{"service"?,"since","stats":Stats,"traces":[TraceSummary]}` | +| `run -- ` | — | `ArgsLenAtDash() != 1` or `< 2` args → `USAGE "usage: keel run -- [args...] (the command goes after --)"` | connect, project, service, `variables:list`, `tracing:localEnv` | none: exec (A10.3) | +| `tracing prompt [service]` | — | 0–1 | connect, project (error tolerated without a service arg), service?, `tracing:prompt {nodeId?, environmentId?}` | `{"prompt"}` (human: the text verbatim, no extra newline) | +| `tracing status ` | — | 1 | connect, project, service, `tracing:forNode` (null → `INVALID_INPUT " is a ; only services can be traced"`, fix `Pick a service: keel service list`) | `{"service","staged","enabled","store","env":[{key,value,secret,overridden}]}` | +| `tracing enable|disable ` | — | 1 | connect, project, service; client check `type != "service"` → same INVALID_INPUT; `tracing:enable {nodeId, on}` | `{"service","tracing":bool,"staged":true}` | +| `var list ` (`vars`,`variables`; `ls`) | `--show-secrets` | 1 | connect, project, service, `variables:list` | `{"service","variables":[{key,value:string|null,resolved:string|null,secret}]}` | +| `var set KEY=VALUE…` | `--secret` | each pair `strings.Cut(arg,"=")`, empty key/no `=` → `USAGE "\"\" is not KEY=VALUE"` | connect, project, service, `variables:list` (for current secret flags), then `variables:set` per pair in order | `{"service","set":[keys],"staged":true}` | +| `var delete KEY…` (`rm`,`unset`) | — | 2+ args | connect, project, service, `variables:list`; any key missing → `VARIABLE_NOT_FOUND " has no variable ; nothing was deleted"`, fix `keel var list ` (checked for all before deleting any); then `variables:remove` per key | `{"service","deleted":[keys],"staged":true}` | +| `ship [service…]` | `-d/--detach`, `--timeout` (default 10m) | | connect, project, `nodes:list` (names → ids), `deployments:start {environmentId, refresh:false, only?}`, then detach or await | `{"deployment":Deployment}` | +| `redeploy ` | same | 1+ args | same with `refresh:true`, `only` always set | same | +| `deployment list ` (`deployments`; `ls`) | — | 1 | connect, project, service, `deployments:listForNode` (logs dropped) | `{"service","deployments":[Deployment]}` | +| `deployment get [id]` | `--wait`, `--timeout` (10m) | 0–1 | connect; id → `deployments:get {id}`, else project + `deployments:latest`; null → `DEPLOYMENT_NOT_FOUND` (`Deployment not found` / `This project was never deployed`), fix `keel deployment list `; `--wait` and status ≠ `success` → resolve services (best effort) and await | `{"deployment":Deployment}` (with log) | + +### A10.1 Details that are easy to miss + +- `var list` view: `secret` printed = `row.secret || row.resolvedSecret`. `value` is shown unless + `!--show-secrets && row.secret`; `resolved` is shown unless `!--show-secrets && (row.secret || + row.resolvedSecret)`. Hidden → `null` in JSON, `••••••••` in the table; stderr notes + ` secret value(s) hidden; --show-secrets reveals them`. Table: `KEY VALUE` where value is + `value` or `value → resolved` when they differ. +- `var set` keeps each existing key's secret flag unless `--secret` was **given** (then `--secret` + or `--secret=false` decides). A failure part-way: the error's message gets ` (after A, B went + through)` and `Extra {"done":["A","B"]}` (only when ≥ 1 key was done and the error is an + `*output.Error`). Same for `var delete`. +- `service create` prints the node as `nodes:list` renders it, so `staged: true`, `status: + "pending"`, `replicas` = requested or 1, `port` = requested or 80. +- `ship` naming a service that has **no** staged change still deploys it (server: `only` + bypasses `dirty`). Names of groups/volumes resolve (they are in the list) but the server drops + non-deployable nodes → possibly `NOTHING_TO_SHIP`. +- `--detach`: after start, `deployments:get` once and print it; human `Started: \nWatch it: keel deployment get --wait`. +- `deployment get --wait` on an already `failed` deployment → await → `DEPLOYMENT_FAILED` + (non-zero). Without `--wait` a failed deployment prints with exit 0. +- `tracing status` human: `Tracing for : on|off[ (staged changes; keel ship deploys them)]`; + store `off` → `Nowhere to send spans yet: open Observability in the dashboard and Sign in with Axiom`; + `old` → `This Axiom connection predates traces: Sign in with Axiom again on Observability`; + when enabled, a table `KEY VALUE [(replaced by the service's own variables)]`. +- `whoami` human table: `User email (name)`, `Organization name (role)` or `none yet`, `Instance name url`. +- `status` human: `Project slug · env`, `Instance`, `Servers N`, `Staged N service(s) → keel ship` or `nothing`, `Last deploy status · message · `, blank line, services table `NAME TYPE STATUS IMAGE REPLICAS URL` (`status (staged)` when dirty, `-` for empty, `running/replicas`). +- `ago`: `<10s` "just now", `<1m` "Ns ago", `<1h` "Nm ago", `<48h` "Nh ago", else "Nd ago". Durations: `finishedAt - startedAt` rounded to 1s, `-` when running. + +### A10.2 Deployment await (`deploy.go`) + +``` +names := map serviceId → name (from the nodes:list done before start) +ctx := WithTimeout(ctx, --timeout) // default 10m; the server times out at 5m +loop: first iteration immediate, then every 1s + d := deployments:get(id) + if ctx.Err() → DeadlineExceeded: TIMEOUT "Still deploying after the --timeout; it keeps going on the server" + Canceled: CANCELLED "Stopped waiting; the deployment keeps going on the server" + fix for both: "keel deployment get --wait" + d == nil → DEPLOYMENT_NOT_FOUND "Deployment not found" + first time: stderr " ()" + if printed > len(d.log): printed = 0 // server keeps the last 500 entries + for each new log entry: stderr " : " (unless text already starts with ":") or " " + running → continue + success → print {"deployment": d}; human "Deployed: in " + failed → failed := labels of steps with status "failed" and a serviceId + error DEPLOYMENT_FAILED, message "Deployment failed: " + (failed joined ", " or d.message), + fix "keel logs " or "keel deployment get ", Extra {"deployment": d} +``` + +### A10.3 `keel run` (`run.go`, `run_unix.go`) + +- Endpoint = `strings.TrimRight(convexSiteURL, "/") + "/otlp"` (the SDK appends `/v1/traces`; + the server route is `POST /otlp/v1/traces`). +- `runEnv(shell, vars, tracing, endpoint)` builds the child env in layers, each key only if the + previous layers left it unset: (1) the shell's own env (`os.Environ()`); (2) each service + variable's **resolved** value, except values matching `\bsvc-[0-9a-z]{32}\b` (an overlay + hostname: a Convex id is 32 chars of `[0-9a-z]`), which are skipped and reported by key (only if + the shell didn't set them); (3) if tracing env is non-nil: `OTEL_EXPORTER_OTLP_ENDPOINT=` + first, then the tracing map in sorted key order, skipping `OTEL_EXPORTER_OTLP_HEADERS` when the + shell or service already set `OTEL_EXPORTER_OTLP_ENDPOINT` or + `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (the ingest key only goes to Keel's endpoint). +- Warnings (stderr): skipped keys → `warning: left out A, B: it points|they point inside the cluster; set it|them in this shell to run against something reachable`; no tracing → + `warning: no tracing variables: (open Observability in )`; with tracing → + progress `: tracing to as deployment.environment.name=local; keel traces shows the requests`. +- Exec: `exec.LookPath(argv[0])` failure or `Start` failure → `USAGE "Can't run : "`, fix + `Check the command after --`. stdin/stdout/stderr inherited. +- Signals: keel `signal.Notify`s SIGINT, SIGTERM, SIGHUP, SIGQUIT. stdin is a TTY → child shares the + foreground process group, so SIGINT is not forwarded (the terminal already sent it), other signals + go to the child. Not a TTY → child gets its own process group (`Setpgid`) and every signal goes to + the whole group (`kill(-pid, sig)`). Non-unix: no group, `Process.Signal`. +- Exit: child killed by signal → `childExit{128+signal}`; else `childExit{exitCode}`; nothing printed. + +### A10.4 `logs` details + +- One-shot: stable-sort lines by `time`, keep the last `n` (Docker tails every task including old + replicas, so the server may return more). Human: `YYYY-MM-DD HH:MM:SS ` in local time; no + lines → stderr `No log lines for yet`. +- `--follow`: prints the initial (cut) tail as events, marks the older fetched lines as seen, then + every 2s polls `logs:tail` with `tail: 200` under a 15s per-poll timeout. `TIMEOUT`/`NETWORK_ERROR` + → `warning: ; retrying`; other errors end the command. Ctx done → return nil (exit 0). + JSON event per line: `{"service","time","stream","task"?,"text"}`. +- De-dup (`lineSet`): keeps `last` time and the set of keys (`task\0stream\0text`) printed at `last`. + A line older than `last` is dropped; newer resets the set; equal time prints only if its key is new. + +### A10.5 `traces` details + +`traces:overview` → CLI renames `duration` → `durationMs`, `stats.p50/p95/p99` → +`p50Ms/p95Ms/p99Ms`. Human: `TIME SERVICE REQUEST STATUS DURATION SPANS` (+ `local` column), +status = `httpStatus` or `error` or `ok`; ms formatting `<1` `%.2fms`, `<1000` `%.1fms`, else a +Go duration rounded to 10ms. stderr summary: ` request(s) in the last · failed · p50 · p95 · trace ids with --json`. Empty → stderr `No requests from in the last `. + +## A11. Convex functions the CLI calls + +Auth rule shorthand: **member** = signed-in user whose Better Auth `member` row's +`organizationId` equals the project's `organizationId` (`ownedProject`/`ownedEnvironment`/ +`ownedNode` return null otherwise). One organization per install. + +| Function | Kind | Args | Returns (fields the CLI reads) | Access / errors | Side effects | +| --- | --- | --- | --- | --- | --- | +| `auth:getCurrentUser` | query | `{}` | user doc or `null`; CLI reads `_id`, `email`, `name` | none; null when signed out | — | +| `organizations:current` | query | `{}` | `{id,name,slug,role}` or `null` | null when no membership | — | +| `projects:list` | query | `{}` | `[{id,name,slug,environments:[{id,name,isProduction}]}]`, environments production-first | no membership: signed out → `Not authenticated`; an organization exists → `You're not in an organization yet. Ask a member for an invite link.`; none exists → `[]` | — | +| `projects:create` | mutation | `{name: string}` | `{id,name,slug,environments:[{id,name:"production",isProduction:true}]}` | `joinOrFound` (below); `Project name: 1–60 characters` (trimmed, JS length); `Project name needs a letter or digit (a-z, 0-9)`; `Project "" already exists` | may found org + owner membership; inserts project + production environment | +| `environments:summary` | query | `{environmentId}` | `{pendingChanges, counts:{status:n}, servers}` or `null` | null when not member | — | +| `nodes:list` | query | `{environmentId}` | `[view]`; CLI reads `id,name,type,status,image?,port?,replicas,running,dirty,publicUrl?,error?` and drops `type=="group"` | `[]` when not member | — | +| `nodes:create` | mutation | `{environmentId, type, name?, position?, image?, engine?, port?, replicas?, deploy?}` (CLI: type `service`, name, image, port?, replicas?) | `{id, deploymentId?}` | `Environment not found`; validation (A9 list); `"" is already taken` (checked **before** the name regex) | inserts node (`desired {image, revision:0, replicas ?? 1, port ?? 80}`, `dirty:true`, position right of the rightmost top-level node: `x + (config.width ?? 220) + 60`, same `y`, else `0,0`) | +| `nodes:remove` | mutation | `{id}` | — | **silently returns** when the node is missing or not the caller's | marks referrers dirty, deletes its variables, un-parents children, cancels `observeScheduled`, deletes the row; if endpoints → `proxy.sync`; if `desired` → `swarm.remove {id}` and `reconcile.run` | +| `variables:list` | query | `{nodeId}` | `[{key,value,resolved,secret,resolvedSecret,parts}]` | `[]` when not member | — | +| `variables:set` | mutation | `{nodeId,key,value,secret,previousKey?}` (CLI never sends previousKey) | — | `Node not found`; `Key: UPPER_SNAKE_CASE only` (`^[A-Z_][A-Z0-9_]{0,63}$`); `Value too long` (> 4096 JS chars) | upsert by key, `dirty:true` on the node, `markReferrersDirty` (transitive) | +| `variables:remove` | mutation | `{nodeId,key}` | — | `Node not found`; missing key → no-op | delete, `dirty:true`, `markReferrersDirty` | +| `logs:tail` | action | `{nodeId, tail?}` (default 200, clamped 1–1000) | `{source:"docker"|"axiom", lines:[{time,text,stream,task}], replicas}` | `Node not found` | reads Axiom (org sink) or `docker service logs svc-` | +| `traces:overview` | action | `{environmentId, range:"15m"|"1h"|"24h"|"7d", search?, nodeId?}` (search cut to 200) | `{source,from,to,bucketMs,stats:{requests,errors,p50,p95,p99},buckets,traces:[{traceId,name,service,kind,start,duration,httpStatus,spans,errors,error,local}]}` | `Environment not found`; `Connect Axiom to see traces`; `Sign in with Axiom again to turn on traces`; `Node not found` (node not in the env) | Axiom APL query | +| `tracing:forNode` | query | `{nodeId}` | `null` (not member / not a service / no desired) or `{enabled, traces:"off"|"old"|"on", env:[{key,value,secret,overridden}]}` (ingest key masked `keel_otlp_…`) | — | — | +| `tracing:enable` | action | `{nodeId, on}` | — | `Node not found`; `Only services can be traced`; when `on`: the two TRACES_OFF messages | `on`: ensure the environment's OTLP ingest key; toggles `desired.tracing`, `dirty:true` (no-op if unchanged) | +| `tracing:localEnv` | action | `{nodeId}` | `{env: {KEY: value} | null, reason: string | null}` | `Node not found`; `Only services can be traced` | ensures the ingest key when traces are `on` | +| `tracing:prompt` | query | `{nodeId?, environmentId?}` | string (the agent prompt, `tracingPrompt.ts`) | never throws; names service/project only when visible to the caller | — | +| `deployments:start` | mutation | `{environmentId, only?: Id[], refresh?: bool}` | deployment id (string) | `Environment not found`; `A deployment is already running`; `Nothing to ship` | bumps `desired.revision`, clears `dirty`/`applyError`, sets `shippedAt`; inserts deployment `{status:"running", message, steps:[…nodes, {label:"health checks"}], log:[]}`; schedules `swarm.apply {id, deploymentId, pull: refresh}` per node and `reconcile.timeoutDeployment` after 5 min | +| `deployments:get` | query | `{id: string}` (any string; malformed → null) | doc or `null` | null when not member | — | +| `deployments:latest` | query | `{environmentId}` | newest doc (by creation) or `null` | null when not member | — | +| `deployments:listForNode` | query | `{nodeId}` | newest 50 of the environment, filtered to those with a step for the node, first 20 | `[]` when not member | — | + +`joinOrFound` (projects:create, also ensureDefault): requires a user (`Not authenticated`); has a +membership → use it; else if any organization exists → `You're not in an organization yet. Ask a +member for an invite link.`; else create organization `{name:"Default", slug:"default"}` and a +`member {role:"owner"}` for the caller. + +`slugOf(name)`: NFKD normalize → strip U+0300–U+036F → lowercase → replace runs of +`[^a-z0-9]+` with `-` → take the first 40 chars → trim leading/trailing `-`. (`Acme API` → `acme-api`.) + +`deployments:start` message: `verb ?? (only ? (refresh ? "redeploy" : "deploy") : "ship")` + `" "` ++ affected names joined `", "`. Affected = nodes with `desired` and type in +`{service,database,cache}` that are in `only` (if given) else `dirty`. + +### CLI-side types (their JSON names are the contract) + +- `User {id,email,name}`, `Organization {id,name,slug,role}`, `Project {id,name,slug,environments}`, `Environment {id,name,isProduction}`. +- `Service {id,name,type,status,image?,port?(0 omitted),replicas,running,staged,publicUrl?,error?}` — `staged` = server `dirty`; `status` ∈ `healthy|done|deploying|stopping|error|stopped|pending`; `type` ∈ `service|database|cache|volume`. +- `Deployment {id,status:"running"|"success"|"failed",message,startedAt,finishedAt?,steps:[{serviceId?,label,status:"pending"|"running"|"done"|"failed"}],log?:[{at,serviceId?,text}]}` — renamed from the doc's `_id`, `nodeId`. +- `LogLine {time,stream:"stdout"|"stderr",task?,text}`, tail `{source,lines}`. +- `TraceSummary {traceId,name,service,start,durationMs,httpStatus:int|null,spans,errors,error,local}`; `TraceStats {requests,errors,p50Ms,p95Ms,p99Ms}` (null when no requests). +- `Tracing {enabled, store:"off"|"old"|"on", env:[{key,value,secret,overridden}]}`. + +### Tests that pin this behaviour (port them) + +`api_test.go` (translate table incl. fixes), `auth_test.go` keel (device code/token request +bodies, Origin header, outcome table), `cli/auth_test.go` (finishLogin: pending, approved saves +token, slow_down re-polls once, expired does not poll, token taken by a concurrent run, nothing +pending, denied forgets the link, login keeps the token when Connect fails, loginTarget ignores +KEEL_TOKEN), `cli_test.go` (pickProject, USAGE before connecting for 16 arg lists, runEnv, +findService, normalizeURL, lineSet, jsonArg), `config_test.go` (LinkFor walks up, 0600, round +trip, RemoveInstance), `convex_test.go` (nil args → `{}`, error cleaning, 401 → unauthenticated), +`output_test.go` (`ok` first, no HTML escaping, JSON failure shape, human failure keeps stdout empty). + +## A12. Web image and `/config.js` + +`apps/web/Dockerfile` (context = repo root): + +1. `node:24-slim` + bun copied from `oven/bun:1`; `bun x turbo@2.10.12 prune web --docker`. +2. Build stage: copy `out/json`, `bun install --frozen-lockfile --ignore-scripts`, copy `out/full`, + `NODE_ENV=production`, `cd apps/web && bun x varlock codegen && bun run build` (vite → `dist`). +3. `nginx:1.29-alpine`: `dist` → `/usr/share/nginx/html`, `nginx.conf` → `conf.d/default.conf`, + `docker-entrypoint.sh` → `/docker-entrypoint.d/40-keel-config.sh`. `EXPOSE 80`. + +Entrypoint: requires `KEEL_CONVEX_URL` and `KEEL_CONVEX_SITE_URL`; each must start with +`http://`/`https://` and contain none of `" \ < >` or space (else exit 1 with +`40-keel-config: …`); writes `/usr/share/nginx/html/config.js`. + +nginx: `listen 80`, gzip for text/css/json/js/svg; `/config.js` → `Cache-Control: no-store`; +`/assets/` → `Cache-Control: public, max-age=31536000, immutable`; SPA fallback +`try_files $uri $uri/ /index.html`. `index.html` loads `` synchronously in ``, before the + module script `/src/main.tsx`. +2. `/config.js` defines `window.__KEEL__`: + - Dev: `apps/web/public/config.js` = `window.__KEEL__ = {};` (Vite serves `public/` as-is). + - Production image (`apps/web/Dockerfile`: static build on `nginx:1.29-alpine`): + `docker-entrypoint.sh`, installed as `/docker-entrypoint.d/40-keel-config.sh`, runs before + nginx starts. It requires `KEEL_CONVEX_URL` (Convex API origin, e.g. `http://100.64.0.1:3210`) + and `KEEL_CONVEX_SITE_URL` (Convex HTTP actions origin, e.g. `http://100.64.0.1:3211`), exits 1 + with `40-keel-config: not an http(s) URL: ` unless each starts with `http://` or `https://`, + and with `40-keel-config: invalid character in URL: ` if it contains `"`, `\`, `<`, `>` or + a space. Then it writes exactly: + `window.__KEEL__ = {"convexUrl":"","convexSiteUrl":""};\n` + to `/usr/share/nginx/html/config.js`. + - `nginx.conf`: `location = /config.js { add_header Cache-Control "no-store"; }`, + `/assets/` → `Cache-Control: public, max-age=31536000, immutable`, everything else + `try_files $uri $uri/ /index.html` (SPA fallback), gzip on for text/css/json/js/svg. +3. `src/lib/config.ts` (module-load time): + ```ts + export const config = { + convexUrl: required("convexUrl", window.__KEEL__?.convexUrl || ENV.VITE_CONVEX_URL), + convexSiteUrl: required("convexSiteUrl", window.__KEEL__?.convexSiteUrl || ENV.VITE_CONVEX_SITE_URL), + }; + // required() throws `${name} is not configured (window.__KEEL__ or .env)` → blank page. + ``` + `ENV` comes from varlock (`.env.schema`: `VITE_CONVEX_URL` and `VITE_CONVEX_SITE_URL` are + `@public @optional @type=url(matches="^(?!https?://example[.]convex[.])")`). +4. Consumers: `main.tsx:14` `new ConvexReactClient(config.convexUrl)`; `lib/auth-client.ts:8` + `createAuthClient({ baseURL: config.convexSiteUrl, … })`. Nothing else reads it. +5. The CLI also reads `/config.js` (`apps/cli/internal/keel/auth.go` `Discover`): GETs + `/config.js`, takes the bytes between the first `{` and the last `}`, JSON-decodes + `{convexUrl, convexSiteUrl}`; non-200 or unparsable → `DISCOVERY_FAILED` "`` doesn't look + like a Keel dashboard (no /config.js)"; either field empty → "``/config.js has no Convex + URLs (a dev server?)". CLAUDE.md: "keep that file's shape". + +### 2.2 Target + +- Dashboard, API and WebSocket share one origin (`keel serve`). The web needs **no** runtime URL: + the generated client uses relative `/api/...`; the WebSocket URL is derived from + `window.location` (`ws(s):///api/ws`). Delete `lib/config.ts`, `public/config.js`, + `docker-entrypoint.sh`, `nginx.conf`, the nginx `Dockerfile` stage, and the two `VITE_CONVEX_*` + entries in `.env.schema` (keep `NODE_ENV`). Remove the `"`, Version: "v"}, + `window.__KEEL__ = {"apiUrl":"http://x/\u003c/script\u003e\"","version":"v"};` + "\n"}, + } { + got := ConfigJS(tc.cfg) + if got != tc.want { + t.Fatalf("ConfigJS(%+v) =\n%s\nwant\n%s", tc.cfg, got, tc.want) + } + // What the CLI's discovery does: the JSON between the first "{" and the last "}". + var v map[string]string + if err := json.Unmarshal([]byte(got[strings.Index(got, "{"):strings.LastIndex(got, "}")+1]), &v); err != nil { + t.Fatalf("CLI cannot parse %q: %v", got, err) + } + if v["apiUrl"] != tc.cfg.SiteURL || v["version"] != tc.cfg.Version { + t.Fatalf("parsed %v", v) + } + } +} + +// fakeAdapter records what serve handed it and when it was released. +type fakeAdapter struct { + mu sync.Mutex + app *app.App + closed bool +} + +func (f *fakeAdapter) build(a *app.App, _ *slog.Logger) (func() error, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.app = a + return func() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil + }, nil +} + +func TestServeOn(t *testing.T) { + fake := &fakeAdapter{} + saved := adapters + adapters = []adapter{{"fake", fake.build}} + t.Cleanup(func() { adapters = saved }) + + dir := t.TempDir() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + base := "http://" + ln.Addr().String() + web := fstest.MapFS{ + "index.html": {Data: []byte("Keel")}, + "assets/app.js": {Data: []byte("console.log(1)")}, + } + cfg := app.Config{Version: "1.2.3", SiteURL: "http://keel.test", DataDir: filepath.Join(dir, "data")} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- serveOn(ctx, ln, cfg, Options{Version: cfg.Version, Web: web}, slog.New(slog.NewTextHandler(io.Discard, nil))) + }() + + get := func(path string) (*http.Response, string) { + t.Helper() + resp, err := http.Get(base + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, string(b) + } + + resp, body := get("/config.js") + if resp.StatusCode != 200 || body != ConfigJS(cfg) || resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("/config.js: %d %q %v", resp.StatusCode, body, resp.Header) + } + if resp, body = get("/api/meta"); resp.StatusCode != 200 || !strings.Contains(body, `"version":"1.2.3"`) { + t.Fatalf("/api/meta: %d %s", resp.StatusCode, body) + } + for _, path := range []string{"/", "/p/acme"} { // SPA fallback + if resp, body = get(path); resp.StatusCode != 200 || !strings.Contains(body, "Keel") { + t.Fatalf("%s: %d %s", path, resp.StatusCode, body) + } + } + if resp, _ = get("/assets/app.js"); !strings.Contains(resp.Header.Get("Cache-Control"), "immutable") { + t.Fatalf("/assets: %v", resp.Header) + } + + // The adapter ran with the jobs scheduler and the realtime publisher already in place. + fake.mu.Lock() + a := fake.app + fake.mu.Unlock() + if a == nil { + t.Fatal("adapter not built") + } + if _, ok := a.Events.(*realtime.Server); !ok { + t.Fatalf("app.Events = %T, want the realtime server", a.Events) + } + if _, ok := a.Jobs.(*jobs.Scheduler); !ok { + t.Fatalf("app.Jobs = %T, want the jobs scheduler", a.Jobs) + } + ran := make(chan struct{}) + a.Jobs.After("test", 0, func(context.Context) { close(ran) }) + select { + case <-ran: + case <-time.After(2 * time.Second): + t.Fatal("job did not run") + } + + // /api/ws is mounted: a session that does not resolve is told it is signed out. + wsctx, wscancel := context.WithTimeout(context.Background(), 5*time.Second) + defer wscancel() + ws, _, err := websocket.Dial(wsctx, "ws://"+ln.Addr().String()+"/api/ws", &websocket.DialOptions{ + HTTPHeader: http.Header{"Cookie": {"keel_session=nope"}, "Origin": {base}}, + }) + if err != nil { + t.Fatalf("dial /api/ws: %v", err) + } + if err := ws.Write(wsctx, websocket.MessageText, []byte(`{"id":1,"connect":{}}`)); err != nil { + t.Fatal(err) + } + for { + if _, _, err = ws.Read(wsctx); err != nil { + break + } + } + if got := websocket.CloseStatus(err); got != 4501 { + t.Fatalf("ws close %d (%v), want 4501", got, err) + } + + // Graceful shutdown: returns cleanly, releases the adapter, leaves a reopenable database. + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("serveOn: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("serveOn did not return after cancel") + } + fake.mu.Lock() + closed := fake.closed + fake.mu.Unlock() + if !closed { + t.Fatal("adapter not released at shutdown") + } + if _, err := http.Get(base + "/api/meta"); err == nil { + t.Fatal("still accepting connections after shutdown") + } + store, err := sqlite.Open(context.Background(), filepath.Join(cfg.DataDir, "keel.db")) + if err != nil { + t.Fatalf("reopen database: %v", err) + } + store.Close() +} + +func TestWireAdaptersReleasesOnFailure(t *testing.T) { + first := &fakeAdapter{} + saved := adapters + adapters = []adapter{ + {"first", first.build}, + {"broken", func(*app.App, *slog.Logger) (func() error, error) { return nil, io.ErrUnexpectedEOF }}, + } + t.Cleanup(func() { adapters = saved }) + _, err := wireAdapters(app.New(app.App{}), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil || !strings.Contains(err.Error(), "broken: unexpected EOF") { + t.Fatalf("err = %v", err) + } + if !first.closed { + t.Fatal("adapter built before the failure was not released") + } +} From 9c83fae2464b32daa2e4908cbbd85eceec458fd4 Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:59:12 +0000 Subject: [PATCH 015/249] auth: domain rules, device flow decisions, password hashing (argon2id + Better Auth scrypt) Co-Authored-By: Claude Opus 5.5 (1M context) --- go.mod | 1 + go.sum | 2 + internal/adapters/password/password.go | 179 +++++++++++ internal/adapters/password/password_test.go | 90 ++++++ internal/domain/actor.go | 5 + internal/domain/auth.go | 55 ++++ internal/domain/auth_device.go | 215 +++++++++++++ internal/domain/auth_errors.go | 47 +++ internal/domain/auth_rules.go | 87 ++++++ internal/domain/auth_test.go | 324 ++++++++++++++++++++ 10 files changed, 1005 insertions(+) create mode 100644 internal/adapters/password/password.go create mode 100644 internal/adapters/password/password_test.go create mode 100644 internal/domain/auth_device.go create mode 100644 internal/domain/auth_errors.go create mode 100644 internal/domain/auth_rules.go create mode 100644 internal/domain/auth_test.go diff --git a/go.mod b/go.mod index 5c19848..ccb03e5 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 179ede4..01fd3b1 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= diff --git a/internal/adapters/password/password.go b/internal/adapters/password/password.go new file mode 100644 index 0000000..c2059d2 --- /dev/null +++ b/internal/adapters/password/password.go @@ -0,0 +1,179 @@ +// Package password hashes and verifies account passwords (app.Passwords). +// +// New hashes are argon2id in the PHC string format. Accounts imported from Better Auth keep +// their scrypt hash (":", docs/go/spec/auth-orgs.md §4.5) until the next +// successful sign-in, when Verify asks the caller to rehash. +package password + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/scrypt" + "golang.org/x/text/unicode/norm" +) + +// Params are argon2id's costs. Memory is in KiB. +type Params struct { + Memory uint32 + Time uint32 + Threads uint8 + SaltLen int + KeyLen uint32 +} + +// DefaultParams: OWASP's argon2id minimum (19 MiB, 2 passes, 1 lane). Light enough for a small +// control-plane VM; the sign-in limiter bounds how often anyone can make us pay it. +var DefaultParams = Params{Memory: 19 * 1024, Time: 2, Threads: 1, SaltLen: 16, KeyLen: 32} + +// Hasher implements app.Passwords. The zero value uses DefaultParams. +type Hasher struct { + Params Params +} + +// New is a Hasher with DefaultParams. +func New() *Hasher { return &Hasher{Params: DefaultParams} } + +func (h *Hasher) params() Params { + if h == nil || h.Params.Memory == 0 { + return DefaultParams + } + return h.Params +} + +// Better Auth's scrypt parameters (@better-auth/utils 0.4.1). +const ( + scryptN = 16384 + scryptR = 16 + scryptP = 1 + scryptKeyLen = 64 +) + +// Limits on a stored argon2id hash's costs, so a corrupt row cannot make a sign-in allocate +// gigabytes. +const ( + maxMemory = 1 << 20 // 1 GiB in KiB + maxTime = 16 + maxThreads = 16 + maxKeyLen = 128 +) + +// normalize: Better Auth hashes password.normalize("NFKC"); new hashes do the same so a password +// typed with a different Unicode composition still matches. +func normalize(password string) []byte { return []byte(norm.NFKC.String(password)) } + +var b64 = base64.RawStdEncoding + +// Hash is a fresh argon2id hash: $argon2id$v=19$m=,t=,p=$$. +func (h *Hasher) Hash(password string) (string, error) { + p := h.params() + salt := make([]byte, p.SaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey(normalize(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, p.Memory, p.Time, p.Threads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil +} + +// Verify reports whether password matches hash and whether the hash should be replaced with a +// fresh Hash (a Better Auth scrypt hash, or argon2id with other costs). An empty or malformed hash +// never matches, but costs about as much as a real check so a missing account is not revealed by +// timing. +func (h *Hasher) Verify(hash, password string) (ok, rehash bool) { + switch { + case strings.HasPrefix(hash, "$argon2id$"): + p, salt, key, err := parseArgon2id(hash) + if err != nil { + h.dummy(password) + return false, false + } + got := argon2.IDKey(normalize(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen) + if subtle.ConstantTimeCompare(got, key) != 1 { + return false, false + } + cur := h.params() + return true, p.Memory != cur.Memory || p.Time != cur.Time || p.Threads != cur.Threads || + p.KeyLen != cur.KeyLen || len(salt) != cur.SaltLen + case strings.Contains(hash, ":"): + ok := verifyScrypt(hash, password) + return ok, ok // a match is always rehashed to argon2id + } + h.dummy(password) + return false, false +} + +// verifyScrypt checks a Better Auth hash. The salt is the 32 hex characters themselves, as +// ASCII bytes (not hex-decoded); the key is compared as lowercase hex. +func verifyScrypt(hash, password string) bool { + saltHex, keyHex, _ := strings.Cut(hash, ":") + if saltHex == "" || keyHex == "" { + return false + } + want, err := hex.DecodeString(keyHex) + if err != nil || len(want) != scryptKeyLen { + return false + } + got, err := scrypt.Key(normalize(password), []byte(saltHex), scryptN, scryptR, scryptP, scryptKeyLen) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// dummy spends one hash's worth of time. +func (h *Hasher) dummy(password string) { + p := h.params() + _ = argon2.IDKey(normalize(password), make([]byte, p.SaltLen), p.Time, p.Memory, p.Threads, p.KeyLen) +} + +var errMalformed = errors.New("malformed argon2id hash") + +func parseArgon2id(hash string) (Params, []byte, []byte, error) { + // "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v="+strconv.Itoa(argon2.Version) { + return Params{}, nil, nil, errMalformed + } + var p Params + for _, kv := range strings.Split(parts[3], ",") { + k, v, _ := strings.Cut(kv, "=") + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return Params{}, nil, nil, errMalformed + } + switch k { + case "m": + p.Memory = uint32(n) + case "t": + p.Time = uint32(n) + case "p": + if n > maxThreads { + return Params{}, nil, nil, errMalformed + } + p.Threads = uint8(n) + default: + return Params{}, nil, nil, errMalformed + } + } + if p.Memory == 0 || p.Memory > maxMemory || p.Time == 0 || p.Time > maxTime || p.Threads == 0 { + return Params{}, nil, nil, errMalformed + } + salt, err := b64.DecodeString(parts[4]) + if err != nil || len(salt) == 0 { + return Params{}, nil, nil, errMalformed + } + key, err := b64.DecodeString(parts[5]) + if err != nil || len(key) == 0 || len(key) > maxKeyLen { + return Params{}, nil, nil, errMalformed + } + p.SaltLen, p.KeyLen = len(salt), uint32(len(key)) + return p, salt, key, nil +} diff --git a/internal/adapters/password/password_test.go b/internal/adapters/password/password_test.go new file mode 100644 index 0000000..2b9264b --- /dev/null +++ b/internal/adapters/password/password_test.go @@ -0,0 +1,90 @@ +package password + +import ( + "strings" + "testing" +) + +// Vectors made with node:crypto scrypt exactly as @better-auth/utils 0.4.1 hashes +// (NFKC, salt = the hex string's ASCII bytes, N=16384 r=16 p=1 dkLen=64). +var betterAuthVectors = []struct{ password, hash string }{ + {"correct-horse-battery", "0123456789abcdef0123456789abcdef:68b228eae069737062c56fbe239acdc9517ba0c0ed53447e2697faa281de7aab26e6e87571f29175a258c42d8a9fde39d6cd344c160472b76897c7bcfe0a1f53"}, + // "fiancé-Ⅸ-pässword" with a ligature, a roman numeral and a combining diaeresis: NFKC matters. + {"fiancé-Ⅸ-pässword", "a1b2c3d4e5f60718293a4b5c6d7e8f90:d6f43c886f0278532fef79e8f38c9e4cd4e10fdb9dbc7af09e569af41ff565cb4b3e843772829ca1e91aac25d44df66e9d5cec863363cd6c2d89a82fbaeb077c"}, +} + +// Cheap parameters keep the tests fast; production uses DefaultParams. +func fast() *Hasher { return &Hasher{Params: Params{Memory: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}} } + +func TestVerifyBetterAuthScrypt(t *testing.T) { + h := fast() + for _, v := range betterAuthVectors { + ok, rehash := h.Verify(v.hash, v.password) + if !ok || !rehash { + t.Errorf("%q: ok=%v rehash=%v, want true true", v.password, ok, rehash) + } + if ok, _ := h.Verify(v.hash, v.password+"x"); ok { + t.Errorf("%q: wrong password matched", v.password) + } + } + // The NFKC form of the second password is the same password. + if ok, _ := h.Verify(betterAuthVectors[1].hash, "fiancé-IX-pässword"); !ok { + t.Error("NFKC-equivalent password did not match") + } +} + +func TestHashAndVerify(t *testing.T) { + h := fast() + hash, err := h.Hash("correct-horse-battery") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(hash, "$argon2id$v=19$m=64,t=1,p=1$") { + t.Fatalf("hash format: %s", hash) + } + if ok, rehash := h.Verify(hash, "correct-horse-battery"); !ok || rehash { + t.Fatalf("own hash: ok=%v rehash=%v", ok, rehash) + } + if ok, _ := h.Verify(hash, "correct-horse-batterY"); ok { + t.Fatal("wrong password matched") + } + again, _ := h.Hash("correct-horse-battery") + if again == hash { + t.Fatal("salt is not random") + } + // Stronger parameters now: the old hash still verifies and asks for a rehash. + stronger := &Hasher{Params: Params{Memory: 128, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}} + if ok, rehash := stronger.Verify(hash, "correct-horse-battery"); !ok || !rehash { + t.Fatalf("param change: ok=%v rehash=%v", ok, rehash) + } +} + +func TestVerifyRejectsMalformed(t *testing.T) { + h := fast() + for _, hash := range []string{ + "", + "plain", + ":", + "abc:", + ":abcd", + "0123456789abcdef0123456789abcdef:zz", + "0123456789abcdef0123456789abcdef:68b2", // short key + "$argon2id$v=19$m=64,t=1,p=1$c2FsdA$", + "$argon2id$v=18$m=64,t=1,p=1$c2FsdHNhbHQ$a2V5a2V5", + "$argon2id$v=19$m=99999999,t=1,p=1$c2FsdHNhbHQ$a2V5a2V5", + "$argon2id$v=19$m=64,t=0,p=1$c2FsdHNhbHQ$a2V5a2V5", + "$argon2id$v=19$m=64,t=1,p=1,x=2$c2FsdHNhbHQ$a2V5a2V5", + "$argon2i$v=19$m=64,t=1,p=1$c2FsdHNhbHQ$a2V5a2V5", + } { + if ok, rehash := h.Verify(hash, "correct-horse-battery"); ok || rehash { + t.Errorf("%q: ok=%v rehash=%v", hash, ok, rehash) + } + } +} + +func TestZeroValueUsesDefaults(t *testing.T) { + var h Hasher + if h.params() != DefaultParams { + t.Fatalf("zero Hasher params: %+v", h.params()) + } +} diff --git a/internal/domain/actor.go b/internal/domain/actor.go index 903fa08..86eb16d 100644 --- a/internal/domain/actor.go +++ b/internal/domain/actor.go @@ -9,6 +9,11 @@ type Actor struct { Role string // owner | admin | member SessionID string System bool // internal callers: jobs, the agent, the proxy + + // Auth area: when the session ends (unix ms), and whether resolving it on this request pushed + // that out (sliding renewal, auth-orgs.md §4.4). The transport then re-sends the cookie. + SessionExpiresAt int64 + SessionRenewed bool } // SystemActor is used by background jobs and bearer-protected internal routes. diff --git a/internal/domain/auth.go b/internal/domain/auth.go index e8a490a..a361746 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -1,5 +1,7 @@ package domain +import "strings" + // Roles of an organization member. const ( RoleOwner = "owner" @@ -7,6 +9,27 @@ const ( RoleMember = "member" ) +// KnownRole: one of the three roles Better Auth's organization plugin defines. +func KnownRole(role string) bool { + return role == RoleOwner || role == RoleAdmin || role == RoleMember +} + +// HasRole: role (a single role, or Better Auth's comma list) includes want. +func HasRole(role, want string) bool { + for _, r := range strings.Split(role, ",") { + if strings.TrimSpace(r) == want { + return true + } + } + return false +} + +// CanManageInvitations: Better Auth's invitation:create / invitation:cancel permission, which +// only owners and admins have. No Keel resource looks at the role (auth-orgs.md §9.3). +func CanManageInvitations(role string) bool { + return HasRole(role, RoleOwner) || HasRole(role, RoleAdmin) +} + type User struct { ID string `json:"id"` Email string `json:"email"` @@ -22,6 +45,7 @@ type Session struct { ExpiresAt int64 `json:"expiresAt"` CreatedAt int64 `json:"createdAt"` UserAgent string `json:"userAgent,omitempty"` + IP string `json:"ip,omitempty"` // auth area: client address at creation } // Organization: one per install for now, founded by the first account. @@ -32,6 +56,12 @@ type Organization struct { CreatedAt int64 `json:"createdAt"` } +// The install's organization, as joinOrFound founds it (convex/projects.ts ORGANIZATION). +const ( + DefaultOrganizationName = "Default" + DefaultOrganizationSlug = "default" +) + type Member struct { ID string `json:"id"` OrganizationID string `json:"organizationId"` @@ -46,6 +76,7 @@ const ( InvitationPending InvitationStatus = "pending" InvitationAccepted InvitationStatus = "accepted" InvitationCanceled InvitationStatus = "canceled" + InvitationRejected InvitationStatus = "rejected" // auth area: Better Auth's reject endpoint (imported rows) ) // Invitation: the id is the secret in the invite link. @@ -59,3 +90,27 @@ type Invitation struct { ExpiresAt int64 `json:"expiresAt"` CreatedAt int64 `json:"createdAt"` } + +// Standing: pending and not expired (convex/auth.ts pendingInvitation: expiresAt >= now). +func (i Invitation) Standing(now int64) bool { + return i.Status == InvitationPending && i.ExpiresAt >= now +} + +// InviteRole is the role an invitation will grant, after Better Auth's invite-member checks on +// the inviter (steps 4 and 5 of auth-orgs.md §7.2): only owners and admins invite, the role must +// be known, and only an owner may invite an owner. "" means member. +func InviteRole(inviterRole, role string) (string, error) { + if !CanManageInvitations(inviterRole) { + return "", E(CodeForbidden, MsgNotAllowedToInvite) + } + if role == "" { + role = RoleMember + } + if !KnownRole(role) { + return "", Invalid("%s: %s", MsgRoleNotFound, role) + } + if role == RoleOwner && !HasRole(inviterRole, RoleOwner) { + return "", E(CodeForbidden, MsgNotAllowedToInviteWithRole) + } + return role, nil +} diff --git a/internal/domain/auth_device.go b/internal/domain/auth_device.go new file mode 100644 index 0000000..9299080 --- /dev/null +++ b/internal/domain/auth_device.go @@ -0,0 +1,215 @@ +package domain + +import ( + "crypto/rand" + "strings" +) + +// Device authorization (RFC 8628) for `keel login`, exactly as better-auth 1.6.17's +// deviceAuthorization plugin behaves with Keel's options (docs/go/spec/cli-install.md §A8, +// auth-orgs.md §5.3). +const ( + DeviceClientID = "keel-cli" + DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + DeviceCodeTTL = int64(30 * 60 * 1000) // 30 minutes + DeviceIntervalS = 5 // seconds between polls + DeviceCodeLength = 40 + DeviceUserCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + DeviceUserCodeLen = 8 +) + +type DeviceStatus string + +const ( + DevicePending DeviceStatus = "pending" + DeviceApproved DeviceStatus = "approved" + DeviceDenied DeviceStatus = "denied" +) + +// DeviceCode is one `keel login` link. The device code itself (the CLI's secret) is stored only +// as its hash, so it is not here. +type DeviceCode struct { + ID string + UserCode string + ClientID string + Status DeviceStatus + UserID string // "" until a signed-in user looks at the code (binding) + IntervalS int + LastPolledAt *int64 + ExpiresAt int64 + CreatedAt int64 +} + +// DeviceExpired: better-auth's `expiresAt < now`. +func (d DeviceCode) Expired(now int64) bool { return d.ExpiresAt < now } + +const deviceAlnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// NewDeviceCode is 40 random characters from [a-zA-Z0-9] (better-auth deviceCodeLength 40). +func NewDeviceCode() string { + out := make([]byte, 0, DeviceCodeLength) + buf := make([]byte, 64) + for len(out) < DeviceCodeLength { + if _, err := rand.Read(buf); err != nil { + panic(err) + } + for _, b := range buf { + // 62 symbols: reject the top of the byte range so every symbol is equally likely. + if b >= 248 { + continue + } + out = append(out, deviceAlnum[int(b)%len(deviceAlnum)]) + if len(out) == DeviceCodeLength { + break + } + } + } + return string(out) +} + +// NewUserCode is 8 characters, each DeviceUserCodeChars[randomByte % 32], as better-auth makes it. +func NewUserCode() string { + b := make([]byte, DeviceUserCodeLen) + if _, err := rand.Read(b); err != nil { + panic(err) + } + for i := range b { + b[i] = DeviceUserCodeChars[int(b[i])%len(DeviceUserCodeChars)] + } + return string(b) +} + +// CleanUserCode is how the server looks a user code up: dashes removed, nothing else (case is +// not normalised; the dashboard upper-cases before calling). +func CleanUserCode(code string) string { return strings.ReplaceAll(code, "-", "") } + +// DeviceRefusal is an RFC 8628 error: HTTP status, `error` and `error_description`. +type DeviceRefusal struct { + Status int + Code string + Description string +} + +func (r *DeviceRefusal) Error() string { return r.Description } + +func deviceRefuse(status int, code, description string) *DeviceRefusal { + return &DeviceRefusal{Status: status, Code: code, Description: description} +} + +// Device error descriptions (better-auth DEVICE_AUTHORIZATION_ERROR_CODES). +const ( + MsgDeviceInvalidClient = "Invalid client ID" + MsgDeviceInvalidCode = "Invalid device code" + MsgDeviceClientMismatch = "Client ID mismatch" + MsgDeviceSlowDown = "Polling too frequently" + MsgDeviceExpiredCode = "Device code has expired" + MsgDevicePending = "Authorization pending" + MsgDeviceAccessDenied = "Access denied" + MsgDeviceUserNotFound = "User not found" + MsgDeviceBadStatus = "Invalid device code status" + MsgDeviceInvalidUserCode = "Invalid user code" + MsgDeviceExpiredUserCode = "User code has expired" + MsgDeviceProcessed = "Device code already processed" + MsgDeviceNotClaimed = "Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying" + MsgDeviceAuthRequired = "Authentication required" + MsgDeviceNotYoursApprove = "You are not authorized to approve this device authorization" + MsgDeviceNotYoursDeny = "You are not authorized to deny this device authorization" + MsgDeviceGrantType = "Unsupported grant type" +) + +// CheckDeviceClient: POST /device/code accepts only the keel CLI. +func CheckDeviceClient(clientID string) *DeviceRefusal { + if clientID != DeviceClientID { + return deviceRefuse(400, "invalid_client", MsgDeviceInvalidClient) + } + return nil +} + +// PollAction is what one POST /device/token poll does to the stored code. +type PollAction int + +const ( + PollRefuse PollAction = iota // answer Refusal; the row is untouched + PollTouch // set last_polled_at = now, answer Refusal + PollTouchDelete // delete the row, answer Refusal + PollIssue // consume the row (single use) and issue a session to UserID +) + +// PollDecision is the outcome of a poll. +type PollDecision struct { + Action PollAction + Refusal *DeviceRefusal // nil for PollIssue +} + +// DecidePoll evaluates POST /device/token in better-auth's order (cli-install.md §A8). dc is nil +// when no row has the device code. Note: a slow_down does not move last_polled_at. +func DecidePoll(dc *DeviceCode, clientID string, now int64) PollDecision { + reject := func(a PollAction, status int, code, desc string) PollDecision { + return PollDecision{Action: a, Refusal: deviceRefuse(status, code, desc)} + } + if clientID != DeviceClientID { + return reject(PollRefuse, 400, "invalid_grant", MsgDeviceInvalidClient) + } + if dc == nil { + return reject(PollRefuse, 400, "invalid_grant", MsgDeviceInvalidCode) + } + if dc.ClientID != "" && dc.ClientID != clientID { + return reject(PollRefuse, 400, "invalid_grant", MsgDeviceClientMismatch) + } + if dc.LastPolledAt != nil && dc.IntervalS > 0 && now-*dc.LastPolledAt < int64(dc.IntervalS)*1000 { + return reject(PollRefuse, 400, "slow_down", MsgDeviceSlowDown) + } + if dc.Expired(now) { + return reject(PollTouchDelete, 400, "expired_token", MsgDeviceExpiredCode) + } + switch { + case dc.Status == DevicePending: + return reject(PollTouch, 400, "authorization_pending", MsgDevicePending) + case dc.Status == DeviceDenied: + return reject(PollTouchDelete, 400, "access_denied", MsgDeviceAccessDenied) + case dc.Status == DeviceApproved && dc.UserID != "": + return PollDecision{Action: PollIssue} + } + return reject(PollTouch, 500, "server_error", MsgDeviceBadStatus) +} + +// CheckUserCode is GET /device: the code must exist and not be expired. Binding happens when it +// returns nil, the caller is signed in, the code is pending and nobody claimed it yet. +func CheckUserCode(dc *DeviceCode, now int64) *DeviceRefusal { + if dc == nil { + return deviceRefuse(400, "invalid_request", MsgDeviceInvalidUserCode) + } + if dc.Expired(now) { + return deviceRefuse(400, "expired_token", MsgDeviceExpiredUserCode) + } + return nil +} + +// ShouldBind: GET /device by a signed-in user claims an unclaimed pending code for them. +func (d DeviceCode) ShouldBind(userID string) bool { + return userID != "" && d.UserID == "" && d.Status == DevicePending +} + +// DecideDevice is POST /device/approve and /device/deny: only the user the code is bound to may +// decide, once. +func DecideDevice(dc *DeviceCode, userID string, approve bool, now int64) *DeviceRefusal { + if userID == "" { + return deviceRefuse(401, "unauthorized", MsgDeviceAuthRequired) + } + if r := CheckUserCode(dc, now); r != nil { + return r + } + if dc.Status != DevicePending { + return deviceRefuse(400, "invalid_request", MsgDeviceProcessed) + } + if dc.UserID == "" { + return deviceRefuse(400, "invalid_request", MsgDeviceNotClaimed) + } + if dc.UserID != userID { + if approve { + return deviceRefuse(403, "access_denied", MsgDeviceNotYoursApprove) + } + return deviceRefuse(403, "access_denied", MsgDeviceNotYoursDeny) + } + return nil +} diff --git a/internal/domain/auth_errors.go b/internal/domain/auth_errors.go new file mode 100644 index 0000000..ac4573d --- /dev/null +++ b/internal/domain/auth_errors.go @@ -0,0 +1,47 @@ +package domain + +// CodeRateLimited: too many attempts; retry after the response's Retry-After seconds (HTTP 429). +// Added by the auth area for the sign-in limiter: FORBIDDEN would tell a client (an agent, a CI +// script) the request can never succeed, while this one is worth retrying later. Codes are only +// ever added (apps/cli/README.md). +const CodeRateLimited = "RATE_LIMITED" + +// Messages of the auth area, verbatim from better-auth 1.6.17 and convex/auth.ts (the dashboard +// shows them). docs/go/spec/auth-orgs.md §5–§7. +const ( + MsgInvalidEmail = "Invalid email" + MsgPasswordTooShort = "Password too short" + MsgPasswordTooLong = "Password too long" + MsgUserExists = "User already exists. Use another email." + MsgSignUpByInvitation = "Sign-up is by invitation. Ask a member for an invite link." + MsgInvalidEmailOrPassword = "Invalid email or password" + MsgTooManyRequests = "Too many requests. Please try again later." + + MsgNotAllowedToInvite = "You are not allowed to invite users to this organization" + MsgNotAllowedToInviteWithRole = "You are not allowed to invite a user with this role" + MsgRoleNotFound = "Role not found" // + ": " + MsgAlreadyMember = "User is already a member of this organization" + MsgInvitationLimit = "Invitation limit reached" + MsgInvitationNotFound = "Invitation not found" + MsgNotRecipient = "You are not the recipient of the invitation" + MsgMembershipLimit = "Organization membership limit reached" + MsgNotAllowedToCancel = "You are not allowed to cancel this invitation" + // New in Go: accepting an invitation while already in an organization (one membership per + // user, auth-orgs.md §7.4). + MsgAlreadyInOrganization = "You're already in an organization" + + // CSRF (Better Auth's origin check). + MsgMissingOrigin = "Missing or null Origin" + MsgInvalidOrigin = "Invalid origin" +) + +// RateLimitError is a refused attempt and when to retry. It unwraps to a RATE_LIMITED *Error. +type RateLimitError struct { + RetryAfterSeconds int64 +} + +func (e *RateLimitError) Error() string { return MsgTooManyRequests } + +func (e *RateLimitError) Unwrap() error { + return &Error{Code: CodeRateLimited, Message: MsgTooManyRequests} +} diff --git a/internal/domain/auth_rules.go b/internal/domain/auth_rules.go new file mode 100644 index 0000000..084f70d --- /dev/null +++ b/internal/domain/auth_rules.go @@ -0,0 +1,87 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" + "unicode/utf16" +) + +// Account rules ported from better-auth 1.6.17 and convex/auth.ts (docs/go/spec/auth-orgs.md). +// Times are unix milliseconds. +const ( + authDay = int64(24 * 60 * 60 * 1000) + + // Sessions last 7 days and slide: a session used more than a day after its last renewal is + // pushed out to 7 days from now (Better Auth expiresIn / updateAge). + SessionTTL = 7 * authDay + SessionUpdateAge = 1 * authDay + + // Invite links work for a week (INVITATION_TTL_S). + InvitationTTL = 7 * authDay + // Better Auth's organization plugin defaults: pending unexpired invitations per organization + // (checked on invite) and members per organization (checked on accept). + InvitationLimit = 100 + MembershipLimit = 100 + + // Passwords: length in UTF-16 code units, as JavaScript's String.length counts them. + PasswordMinLength = 8 + PasswordMaxLength = 128 + + // SessionTokenBytes of randomness in a session token. + SessionTokenBytes = 32 +) + +// zod 4.6.4's z.email() pattern, which Better Auth validates emails with. +var userEmailRE = regexp.MustCompile(`^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`) + +// ValidUserEmail: the address Better Auth would accept. +func ValidUserEmail(email string) bool { return userEmailRE.MatchString(email) } + +// NormalizeUserEmail is how emails are stored and compared: trimmed, lower-cased. +func NormalizeUserEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } + +// SameUserEmail: convex/auth.ts sameEmail (trimmed, case-insensitive). +func SameUserEmail(a, b string) bool { return NormalizeUserEmail(a) == NormalizeUserEmail(b) } + +// authJSLength is s.length in JavaScript: UTF-16 code units. +func authJSLength(s string) int { return len(utf16.Encode([]rune(s))) } + +// ValidPassword: Better Auth's length rule (min 8, max 128). +func ValidPassword(password string) error { + n := authJSLength(password) + if n < PasswordMinLength { + return Invalid(MsgPasswordTooShort) + } + if n > PasswordMaxLength { + return Invalid(MsgPasswordTooLong) + } + return nil +} + +// HashSessionToken is what the database stores for a session token: hex SHA-256. A legacy Better +// Auth signed value (".") is reduced to its token first, so tokens printed by +// `keel token` before the switch keep working once sessions are imported. +func HashSessionToken(token string) string { + if i := strings.IndexByte(token, '.'); i >= 0 { + token = token[:i] + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// HashSecret is hex SHA-256 of a secret stored only as its hash (device codes). +func HashSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// SessionNeedsRenewal: the session was last renewed more than SessionUpdateAge ago +// (Better Auth: expiresAt - expiresIn + updateAge <= now). +func SessionNeedsRenewal(expiresAt, now int64) bool { + return expiresAt-SessionTTL+SessionUpdateAge <= now +} + +// SessionExpired: Better Auth treats a session as gone once expiresAt is not in the future. +func SessionExpired(expiresAt, now int64) bool { return expiresAt <= now } diff --git a/internal/domain/auth_test.go b/internal/domain/auth_test.go new file mode 100644 index 0000000..4199dc1 --- /dev/null +++ b/internal/domain/auth_test.go @@ -0,0 +1,324 @@ +package domain + +import ( + "errors" + "regexp" + "strings" + "testing" +) + +func authCode(err error) string { + if err == nil { + return "" + } + return CodeOf(err) +} + +func TestValidUserEmail(t *testing.T) { + for email, want := range map[string]bool{ + "ci@example.com": true, + "First.Last+tag@Sub.Example.io": true, + "o'brien@example.com": true, + "a@b.co": true, + "": false, + "plain": false, + "a@b": false, + "a@b.c": false, + ".a@example.com": false, + "a..b@example.com": false, + "a.@example.com": false, + "a@-example.com": false, + " a@example.com": false, + "a@example.com ": false, + "a b@example.com": false, + "a@exa_mple.com": false, + } { + if got := ValidUserEmail(email); got != want { + t.Errorf("ValidUserEmail(%q) = %v, want %v", email, got, want) + } + } +} + +func TestSameUserEmail(t *testing.T) { + if !SameUserEmail(" Ci@Example.COM ", "ci@example.com") { + t.Error("case and spaces should not matter") + } + if SameUserEmail("ci@example.com", "cj@example.com") { + t.Error("different emails matched") + } +} + +func TestValidPassword(t *testing.T) { + cases := []struct { + pw string + want string + }{ + {"1234567", MsgPasswordTooShort}, + {"12345678", ""}, + {strings.Repeat("a", 128), ""}, + {strings.Repeat("a", 129), MsgPasswordTooLong}, + // JavaScript counts UTF-16 units: 4 emoji are 8 units, long enough. + {"😀😀😀😀", ""}, + {"😀😀😀", MsgPasswordTooShort}, + {strings.Repeat("😀", 64), ""}, + {strings.Repeat("😀", 65), MsgPasswordTooLong}, + // 8 runes of 2 bytes each: 8 units. + {"éééééééé", ""}, + } + for _, c := range cases { + err := ValidPassword(c.pw) + got := "" + if err != nil { + got = err.Error() + if CodeOf(err) != CodeInvalidInput { + t.Errorf("%q: code %s", c.pw, CodeOf(err)) + } + } + if got != c.want { + t.Errorf("ValidPassword(%q) = %q, want %q", c.pw, got, c.want) + } + } +} + +func TestHashSessionToken(t *testing.T) { + a := HashSessionToken("abc") + if len(a) != 64 { + t.Fatalf("hash length %d", len(a)) + } + if a != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { + t.Fatalf("sha256(abc) = %s", a) + } + // A legacy Better Auth signed value hashes like its token. + if HashSessionToken("abc.c2lnbmF0dXJl") != a { + t.Fatal("signature suffix not stripped") + } +} + +func TestSessionRenewal(t *testing.T) { + now := int64(100 * SessionTTL) + cases := []struct { + name string + expiresAt int64 + renew bool + expired bool + }{ + {"fresh", now + SessionTTL, false, false}, + {"used 23h after renewal", now + SessionTTL - SessionUpdateAge + 1, false, false}, + {"used exactly a day after renewal", now + SessionTTL - SessionUpdateAge, true, false}, + {"used 3 days after renewal", now + SessionTTL - 3*SessionUpdateAge, true, false}, + {"last millisecond", now + 1, true, false}, + {"expires now", now, true, true}, + {"expired", now - 1, true, true}, + } + for _, c := range cases { + if got := SessionNeedsRenewal(c.expiresAt, now); got != c.renew { + t.Errorf("%s: renew = %v, want %v", c.name, got, c.renew) + } + if got := SessionExpired(c.expiresAt, now); got != c.expired { + t.Errorf("%s: expired = %v, want %v", c.name, got, c.expired) + } + } +} + +func TestInvitationStanding(t *testing.T) { + cases := []struct { + status InvitationStatus + expires int64 + want bool + }{ + {InvitationPending, 10, true}, + {InvitationPending, 9, true}, // expiresAt == now still stands + {InvitationPending, 8, false}, + {InvitationAccepted, 100, false}, + {InvitationCanceled, 100, false}, + {InvitationRejected, 100, false}, + } + for _, c := range cases { + if got := (Invitation{Status: c.status, ExpiresAt: c.expires}).Standing(9); got != c.want { + t.Errorf("%s expiring %d: %v, want %v", c.status, c.expires, got, c.want) + } + } +} + +func TestInviteRole(t *testing.T) { + cases := []struct { + inviter, role string + want string + code, msg string + }{ + {RoleOwner, "", RoleMember, "", ""}, + {RoleOwner, RoleMember, RoleMember, "", ""}, + {RoleOwner, RoleAdmin, RoleAdmin, "", ""}, + {RoleOwner, RoleOwner, RoleOwner, "", ""}, + {RoleAdmin, RoleMember, RoleMember, "", ""}, + {RoleAdmin, RoleAdmin, RoleAdmin, "", ""}, + {RoleAdmin, RoleOwner, "", CodeForbidden, MsgNotAllowedToInviteWithRole}, + {RoleMember, RoleMember, "", CodeForbidden, MsgNotAllowedToInvite}, + {"", RoleMember, "", CodeForbidden, MsgNotAllowedToInvite}, + {RoleOwner, "superuser", "", CodeInvalidInput, "Role not found: superuser"}, + {"admin,member", RoleOwner, "", CodeForbidden, MsgNotAllowedToInviteWithRole}, + {"member, owner", RoleOwner, RoleOwner, "", ""}, + } + for _, c := range cases { + got, err := InviteRole(c.inviter, c.role) + if got != c.want || authCode(err) != c.code || (err != nil && err.Error() != c.msg) { + t.Errorf("InviteRole(%q, %q) = %q, %v; want %q, %s %q", c.inviter, c.role, got, err, c.want, c.code, c.msg) + } + } +} + +func TestDeviceCodes(t *testing.T) { + dc := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`) + uc := regexp.MustCompile(`^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$`) + seen := map[string]bool{} + for range 200 { + d, u := NewDeviceCode(), NewUserCode() + if !dc.MatchString(d) { + t.Fatalf("device code %q", d) + } + if !uc.MatchString(u) { + t.Fatalf("user code %q", u) + } + if seen[d] { + t.Fatal("device code repeated") + } + seen[d] = true + } + if CleanUserCode("ABCD-EFGH") != "ABCDEFGH" || CleanUserCode("abcd-efgh") != "abcdefgh" { + t.Fatal("CleanUserCode strips dashes only") + } + if CheckDeviceClient("keel-cli") != nil { + t.Fatal("keel-cli refused") + } + if r := CheckDeviceClient("other"); r == nil || r.Status != 400 || r.Code != "invalid_client" || r.Description != "Invalid client ID" { + t.Fatalf("other client: %+v", r) + } +} + +func authI64(v int64) *int64 { return &v } + +func TestDecidePoll(t *testing.T) { + const now = int64(1_000_000) + base := DeviceCode{ClientID: DeviceClientID, Status: DevicePending, IntervalS: 5, ExpiresAt: now + 60_000} + with := func(f func(*DeviceCode)) *DeviceCode { d := base; f(&d); return &d } + cases := []struct { + name string + dc *DeviceCode + client string + action PollAction + status int + code string + desc string + }{ + {"wrong client", &base, "other", PollRefuse, 400, "invalid_grant", "Invalid client ID"}, + {"unknown code", nil, DeviceClientID, PollRefuse, 400, "invalid_grant", "Invalid device code"}, + {"client mismatch", with(func(d *DeviceCode) { d.ClientID = "x" }), DeviceClientID, PollRefuse, 400, "invalid_grant", "Client ID mismatch"}, + {"slow down", with(func(d *DeviceCode) { d.LastPolledAt = authI64(now - 4_999) }), DeviceClientID, PollRefuse, 400, "slow_down", "Polling too frequently"}, + {"slow down beats expiry", with(func(d *DeviceCode) { d.LastPolledAt = authI64(now - 1); d.ExpiresAt = now - 1 }), DeviceClientID, PollRefuse, 400, "slow_down", "Polling too frequently"}, + {"interval elapsed", with(func(d *DeviceCode) { d.LastPolledAt = authI64(now - 5_000) }), DeviceClientID, PollTouch, 400, "authorization_pending", "Authorization pending"}, + {"pending", &base, DeviceClientID, PollTouch, 400, "authorization_pending", "Authorization pending"}, + {"expired", with(func(d *DeviceCode) { d.ExpiresAt = now - 1 }), DeviceClientID, PollTouchDelete, 400, "expired_token", "Device code has expired"}, + {"expires now is still valid", with(func(d *DeviceCode) { d.ExpiresAt = now }), DeviceClientID, PollTouch, 400, "authorization_pending", "Authorization pending"}, + {"denied", with(func(d *DeviceCode) { d.Status = DeviceDenied; d.UserID = "u" }), DeviceClientID, PollTouchDelete, 400, "access_denied", "Access denied"}, + {"approved", with(func(d *DeviceCode) { d.Status = DeviceApproved; d.UserID = "u" }), DeviceClientID, PollIssue, 0, "", ""}, + {"approved without user", with(func(d *DeviceCode) { d.Status = DeviceApproved }), DeviceClientID, PollTouch, 500, "server_error", "Invalid device code status"}, + {"no stored client", with(func(d *DeviceCode) { d.ClientID = "" }), DeviceClientID, PollTouch, 400, "authorization_pending", "Authorization pending"}, + } + for _, c := range cases { + got := DecidePoll(c.dc, c.client, now) + if got.Action != c.action { + t.Errorf("%s: action %d, want %d", c.name, got.Action, c.action) + } + if c.action == PollIssue { + if got.Refusal != nil { + t.Errorf("%s: refusal %+v", c.name, got.Refusal) + } + continue + } + r := got.Refusal + if r == nil || r.Status != c.status || r.Code != c.code || r.Description != c.desc { + t.Errorf("%s: %+v, want %d %s %q", c.name, r, c.status, c.code, c.desc) + } + } +} + +func TestDecideDevice(t *testing.T) { + const now = int64(1_000_000) + pending := DeviceCode{Status: DevicePending, UserID: "u1", ExpiresAt: now + 1} + with := func(f func(*DeviceCode)) *DeviceCode { d := pending; f(&d); return &d } + cases := []struct { + name string + dc *DeviceCode + user string + approve bool + status int + code string + desc string + }{ + {"signed out", &pending, "", true, 401, "unauthorized", "Authentication required"}, + {"signed out before lookup", nil, "", true, 401, "unauthorized", "Authentication required"}, + {"unknown", nil, "u1", true, 400, "invalid_request", "Invalid user code"}, + {"expired", with(func(d *DeviceCode) { d.ExpiresAt = now - 1 }), "u1", true, 400, "expired_token", "User code has expired"}, + {"processed", with(func(d *DeviceCode) { d.Status = DeviceApproved }), "u1", true, 400, "invalid_request", "Device code already processed"}, + {"unclaimed", with(func(d *DeviceCode) { d.UserID = "" }), "u1", true, 400, "invalid_request", MsgDeviceNotClaimed}, + {"someone else approves", &pending, "u2", true, 403, "access_denied", "You are not authorized to approve this device authorization"}, + {"someone else denies", &pending, "u2", false, 403, "access_denied", "You are not authorized to deny this device authorization"}, + {"approve", &pending, "u1", true, 0, "", ""}, + {"deny", &pending, "u1", false, 0, "", ""}, + } + for _, c := range cases { + r := DecideDevice(c.dc, c.user, c.approve, now) + if c.status == 0 { + if r != nil { + t.Errorf("%s: refused %+v", c.name, r) + } + continue + } + if r == nil || r.Status != c.status || r.Code != c.code || r.Description != c.desc { + t.Errorf("%s: %+v, want %d %s %q", c.name, r, c.status, c.code, c.desc) + } + } + if !strings.Contains(MsgDeviceNotClaimed, "`GET /device`") { + t.Fatal("not-claimed message must keep its backticks") + } +} + +func TestCheckUserCodeAndBind(t *testing.T) { + const now = int64(50) + if r := CheckUserCode(nil, now); r == nil || r.Code != "invalid_request" || r.Description != "Invalid user code" { + t.Fatalf("unknown: %+v", r) + } + if r := CheckUserCode(&DeviceCode{ExpiresAt: now - 1}, now); r == nil || r.Code != "expired_token" || r.Description != "User code has expired" { + t.Fatalf("expired: %+v", r) + } + if r := CheckUserCode(&DeviceCode{ExpiresAt: now}, now); r != nil { + t.Fatalf("valid: %+v", r) + } + cases := []struct { + dc DeviceCode + user string + want bool + }{ + {DeviceCode{Status: DevicePending}, "u", true}, + {DeviceCode{Status: DevicePending}, "", false}, + {DeviceCode{Status: DevicePending, UserID: "v"}, "u", false}, + {DeviceCode{Status: DeviceApproved}, "u", false}, + {DeviceCode{Status: DeviceDenied}, "u", false}, + } + for _, c := range cases { + if got := c.dc.ShouldBind(c.user); got != c.want { + t.Errorf("ShouldBind(%+v, %q) = %v", c.dc, c.user, got) + } + } +} + +func TestRateLimitError(t *testing.T) { + var err error = &RateLimitError{RetryAfterSeconds: 30} + var de *Error + if !errors.As(err, &de) || de.Code != CodeRateLimited || de.Message != MsgTooManyRequests { + t.Fatalf("RateLimitError unwraps to %+v", de) + } + if CodeOf(err) != CodeRateLimited { + t.Fatalf("CodeOf = %s", CodeOf(err)) + } +} From 7392e6a4bbe14cb2744e518d59b2a5ea043287cf Mon Sep 17 00:00:00 2001 From: Thalles Passos Date: Thu, 8 Oct 2026 02:59:24 +0000 Subject: [PATCH 016/249] Go canvas: projects, environments, nodes, variables use cases and SQLite Domain: node defaults, engines, seeded credentials, naming, placement, reference syntax and resolution (depth 5, encodeURIComponent), referrer graph, reference rewriting. App: projects.ts, environments.ts, nodes.ts, variables.ts use cases; computeEnv and markReferrersDirty seams. SQLite: CanvasTx queries, unique node name per environment (0002_canvas.sql). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/adapters/sqlite/canvas.go | 132 ++++ internal/adapters/sqlite/db/canvas.sql.go | 326 ++++++++++ .../sqlite/migrations/0002_canvas.sql | 4 + internal/adapters/sqlite/queries/canvas.sql | 45 ++ internal/app/environments.go | 48 ++ internal/app/nodes.go | 597 ++++++++++++++++++ internal/app/nodes_seams.go | 76 +++ internal/app/ports_canvas.go | 54 +- internal/app/projects.go | 214 +++++++ internal/app/seams.go | 14 - internal/app/variables.go | 311 +++++++++ internal/domain/canvas.go | 243 +++++++ internal/domain/reference.go | 317 ++++++++++ 13 files changed, 2364 insertions(+), 17 deletions(-) create mode 100644 internal/adapters/sqlite/db/canvas.sql.go create mode 100644 internal/adapters/sqlite/migrations/0002_canvas.sql create mode 100644 internal/adapters/sqlite/queries/canvas.sql create mode 100644 internal/app/environments.go create mode 100644 internal/app/nodes.go create mode 100644 internal/app/nodes_seams.go create mode 100644 internal/app/projects.go create mode 100644 internal/app/variables.go create mode 100644 internal/domain/canvas.go create mode 100644 internal/domain/reference.go diff --git a/internal/adapters/sqlite/canvas.go b/internal/adapters/sqlite/canvas.go index 47500e8..c660bdd 100644 --- a/internal/adapters/sqlite/canvas.go +++ b/internal/adapters/sqlite/canvas.go @@ -1,3 +1,135 @@ package sqlite +import ( + "fmt" + + "github.com/ThallesP/keel/internal/adapters/sqlite/db" + "github.com/ThallesP/keel/internal/app" + "github.com/ThallesP/keel/internal/domain" +) + // Implements app.CanvasTx. Owner: the canvas area. + +// canvasTaken maps a UNIQUE violation to app.ErrCanvasTaken (keeping the driver text). +func canvasTaken(err error) error { + if IsUniqueViolation(err) { + return fmt.Errorf("%w: %v", app.ErrCanvasTaken, err) + } + return err +} + +func variableOf(v db.Variable) domain.Variable { + return domain.Variable{ID: v.ID, NodeID: v.NodeID, Key: v.Key, Value: v.Value, Secret: v.Secret != 0} +} + +func variablesOf(rows []db.Variable) []domain.Variable { + out := make([]domain.Variable, 0, len(rows)) + for _, r := range rows { + out = append(out, variableOf(r)) + } + return out +} + +func (t *tx) CanvasOrganizationExists() (bool, error) { + n, err := t.q.CanvasCountOrganizations(t.ctx) + return n > 0, err +} + +func (t *tx) CanvasProjects(organizationID string) ([]domain.Project, error) { + rows, err := t.q.CanvasListProjects(t.ctx, organizationID) + if err != nil { + return nil, err + } + out := make([]domain.Project, 0, len(rows)) + for _, p := range rows { + out = append(out, projectOf(p)) + } + return out, nil +} + +func (t *tx) CanvasProjectBySlug(organizationID, slug string) (domain.Project, error) { + p, err := t.q.CanvasGetProjectBySlug(t.ctx, db.CanvasGetProjectBySlugParams{OrganizationID: organizationID, Slug: slug}) + if err != nil { + return domain.Project{}, noRow(err) + } + return projectOf(p), nil +} + +func (t *tx) CanvasInsertProject(p domain.Project) error { + return canvasTaken(t.q.CanvasInsertProject(t.ctx, db.CanvasInsertProjectParams{ + ID: p.ID, OrganizationID: p.OrganizationID, Name: p.Name, Slug: p.Slug, CreatedAt: p.CreatedAt, + })) +} + +func (t *tx) CanvasEnvironments(projectID string) ([]domain.Environment, error) { + rows, err := t.q.CanvasListEnvironments(t.ctx, projectID) + if err != nil { + return nil, err + } + out := make([]domain.Environment, 0, len(rows)) + for _, e := range rows { + out = append(out, environmentOf(e)) + } + return out, nil +} + +func (t *tx) CanvasInsertEnvironment(e domain.Environment) error { + return t.q.CanvasInsertEnvironment(t.ctx, db.CanvasInsertEnvironmentParams{ + ID: e.ID, ProjectID: e.ProjectID, Name: e.Name, IsProduction: b2i(e.IsProduction), CreatedAt: e.CreatedAt, + }) +} + +func (t *tx) CanvasClusterServers() (int, error) { + rows, err := t.q.CanvasGetClusterServers(t.ctx) + if err != nil || len(rows) == 0 { + return 0, err + } + return int(rows[0]), nil +} + +func (t *tx) CanvasInsertNode(n domain.Node) error { return canvasTaken(t.InsertNode(n)) } + +func (t *tx) CanvasUpdateNode(n domain.Node) error { return canvasTaken(t.UpdateNode(n)) } + +func (t *tx) CanvasMarkDirty(nodeID string) error { return t.q.CanvasMarkDirty(t.ctx, nodeID) } + +func (t *tx) CanvasVariables(nodeID string) ([]domain.Variable, error) { + rows, err := t.q.CanvasListVariables(t.ctx, nodeID) + if err != nil { + return nil, err + } + return variablesOf(rows), nil +} + +func (t *tx) CanvasEnvironmentVariables(environmentID string) ([]domain.Variable, error) { + rows, err := t.q.CanvasListEnvironmentVariables(t.ctx, environmentID) + if err != nil { + return nil, err + } + return variablesOf(rows), nil +} + +func (t *tx) CanvasInsertVariable(v domain.Variable) error { + return canvasTaken(t.q.CanvasInsertVariable(t.ctx, db.CanvasInsertVariableParams{ + ID: v.ID, NodeID: v.NodeID, Key: v.Key, Value: v.Value, Secret: b2i(v.Secret), + })) +} + +func (t *tx) CanvasUpdateVariable(v domain.Variable) error { + n, err := t.q.CanvasUpdateVariable(t.ctx, db.CanvasUpdateVariableParams{ + ID: v.ID, Key: v.Key, Value: v.Value, Secret: b2i(v.Secret), + }) + if err != nil { + return canvasTaken(err) + } + if n == 0 { + return app.ErrNoRow + } + return nil +} + +func (t *tx) CanvasDeleteVariable(id string) error { return t.q.CanvasDeleteVariable(t.ctx, id) } + +func (t *tx) CanvasDeleteNodeVariables(nodeID string) error { + return t.q.CanvasDeleteNodeVariables(t.ctx, nodeID) +} diff --git a/internal/adapters/sqlite/db/canvas.sql.go b/internal/adapters/sqlite/db/canvas.sql.go new file mode 100644 index 0000000..09b679c --- /dev/null +++ b/internal/adapters/sqlite/db/canvas.sql.go @@ -0,0 +1,326 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: canvas.sql + +package db + +import ( + "context" +) + +const canvasCountOrganizations = `-- name: CanvasCountOrganizations :one + +SELECT COUNT(*) FROM organizations +` + +// Canvas area: projects, environments, variables, the cluster row it reads. +// Creation order is rowid order (insertion order; an UPDATE keeps the rowid). +func (q *Queries) CanvasCountOrganizations(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, canvasCountOrganizations) + var count int64 + err := row.Scan(&count) + return count, err +} + +const canvasDeleteNodeVariables = `-- name: CanvasDeleteNodeVariables :exec +DELETE FROM variables WHERE node_id = ? +` + +func (q *Queries) CanvasDeleteNodeVariables(ctx context.Context, nodeID string) error { + _, err := q.db.ExecContext(ctx, canvasDeleteNodeVariables, nodeID) + return err +} + +const canvasDeleteVariable = `-- name: CanvasDeleteVariable :exec +DELETE FROM variables WHERE id = ? +` + +func (q *Queries) CanvasDeleteVariable(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, canvasDeleteVariable, id) + return err +} + +const canvasGetClusterServers = `-- name: CanvasGetClusterServers :many +SELECT servers FROM cluster WHERE id = 1 +` + +func (q *Queries) CanvasGetClusterServers(ctx context.Context) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, canvasGetClusterServers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var servers int64 + if err := rows.Scan(&servers); err != nil { + return nil, err + } + items = append(items, servers) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const canvasGetProjectBySlug = `-- name: CanvasGetProjectBySlug :one +SELECT id, organization_id, name, slug, created_at FROM projects WHERE organization_id = ? AND slug = ? +` + +type CanvasGetProjectBySlugParams struct { + OrganizationID string + Slug string +} + +func (q *Queries) CanvasGetProjectBySlug(ctx context.Context, arg CanvasGetProjectBySlugParams) (Project, error) { + row := q.db.QueryRowContext(ctx, canvasGetProjectBySlug, arg.OrganizationID, arg.Slug) + var i Project + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Name, + &i.Slug, + &i.CreatedAt, + ) + return i, err +} + +const canvasInsertEnvironment = `-- name: CanvasInsertEnvironment :exec +INSERT INTO environments (id, project_id, name, is_production, created_at) VALUES (?, ?, ?, ?, ?) +` + +type CanvasInsertEnvironmentParams struct { + ID string + ProjectID string + Name string + IsProduction int64 + CreatedAt int64 +} + +func (q *Queries) CanvasInsertEnvironment(ctx context.Context, arg CanvasInsertEnvironmentParams) error { + _, err := q.db.ExecContext(ctx, canvasInsertEnvironment, + arg.ID, + arg.ProjectID, + arg.Name, + arg.IsProduction, + arg.CreatedAt, + ) + return err +} + +const canvasInsertProject = `-- name: CanvasInsertProject :exec +INSERT INTO projects (id, organization_id, name, slug, created_at) VALUES (?, ?, ?, ?, ?) +` + +type CanvasInsertProjectParams struct { + ID string + OrganizationID string + Name string + Slug string + CreatedAt int64 +} + +func (q *Queries) CanvasInsertProject(ctx context.Context, arg CanvasInsertProjectParams) error { + _, err := q.db.ExecContext(ctx, canvasInsertProject, + arg.ID, + arg.OrganizationID, + arg.Name, + arg.Slug, + arg.CreatedAt, + ) + return err +} + +const canvasInsertVariable = `-- name: CanvasInsertVariable :exec +INSERT INTO variables (id, node_id, key, value, secret) VALUES (?, ?, ?, ?, ?) +` + +type CanvasInsertVariableParams struct { + ID string + NodeID string + Key string + Value string + Secret int64 +} + +func (q *Queries) CanvasInsertVariable(ctx context.Context, arg CanvasInsertVariableParams) error { + _, err := q.db.ExecContext(ctx, canvasInsertVariable, + arg.ID, + arg.NodeID, + arg.Key, + arg.Value, + arg.Secret, + ) + return err +} + +const canvasListEnvironmentVariables = `-- name: CanvasListEnvironmentVariables :many +SELECT v.id, v.node_id, v."key", v.value, v.secret FROM variables v JOIN nodes n ON n.id = v.node_id +WHERE n.environment_id = ? ORDER BY v.rowid +` + +func (q *Queries) CanvasListEnvironmentVariables(ctx context.Context, environmentID string) ([]Variable, error) { + rows, err := q.db.QueryContext(ctx, canvasListEnvironmentVariables, environmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Variable{} + for rows.Next() { + var i Variable + if err := rows.Scan( + &i.ID, + &i.NodeID, + &i.Key, + &i.Value, + &i.Secret, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const canvasListEnvironments = `-- name: CanvasListEnvironments :many +SELECT id, project_id, name, is_production, created_at FROM environments WHERE project_id = ? ORDER BY rowid +` + +func (q *Queries) CanvasListEnvironments(ctx context.Context, projectID string) ([]Environment, error) { + rows, err := q.db.QueryContext(ctx, canvasListEnvironments, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Environment{} + for rows.Next() { + var i Environment + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.IsProduction, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const canvasListProjects = `-- name: CanvasListProjects :many +SELECT id, organization_id, name, slug, created_at FROM projects WHERE organization_id = ? ORDER BY rowid +` + +func (q *Queries) CanvasListProjects(ctx context.Context, organizationID string) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, canvasListProjects, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Project{} + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.Name, + &i.Slug, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const canvasListVariables = `-- name: CanvasListVariables :many +SELECT id, node_id, "key", value, secret FROM variables WHERE node_id = ? ORDER BY rowid +` + +func (q *Queries) CanvasListVariables(ctx context.Context, nodeID string) ([]Variable, error) { + rows, err := q.db.QueryContext(ctx, canvasListVariables, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Variable{} + for rows.Next() { + var i Variable + if err := rows.Scan( + &i.ID, + &i.NodeID, + &i.Key, + &i.Value, + &i.Secret, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const canvasMarkDirty = `-- name: CanvasMarkDirty :exec +UPDATE nodes SET dirty = 1 WHERE id = ? +` + +func (q *Queries) CanvasMarkDirty(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, canvasMarkDirty, id) + return err +} + +const canvasUpdateVariable = `-- name: CanvasUpdateVariable :execrows +UPDATE variables SET key = ?2, value = ?3, secret = ?4 WHERE id = ?1 +` + +type CanvasUpdateVariableParams struct { + ID string + Key string + Value string + Secret int64 +} + +func (q *Queries) CanvasUpdateVariable(ctx context.Context, arg CanvasUpdateVariableParams) (int64, error) { + result, err := q.db.ExecContext(ctx, canvasUpdateVariable, + arg.ID, + arg.Key, + arg.Value, + arg.Secret, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/adapters/sqlite/migrations/0002_canvas.sql b/internal/adapters/sqlite/migrations/0002_canvas.sql new file mode 100644 index 0000000..e5f4707 --- /dev/null +++ b/internal/adapters/sqlite/migrations/0002_canvas.sql @@ -0,0 +1,4 @@ +-- Canvas area. Node names are unique per environment: `${{ name.KEY }}` references resolve by +-- name (docs/go/spec/projects.md section 0). Convex enforced it with read-then-write only; the +-- use cases still check first and map a violation to the same message. +CREATE UNIQUE INDEX nodes_environment_name ON nodes(environment_id, name); diff --git a/internal/adapters/sqlite/queries/canvas.sql b/internal/adapters/sqlite/queries/canvas.sql new file mode 100644 index 0000000..1e54fd7 --- /dev/null +++ b/internal/adapters/sqlite/queries/canvas.sql @@ -0,0 +1,45 @@ +-- Canvas area: projects, environments, variables, the cluster row it reads. +-- Creation order is rowid order (insertion order; an UPDATE keeps the rowid). + +-- name: CanvasCountOrganizations :one +SELECT COUNT(*) FROM organizations; + +-- name: CanvasListProjects :many +SELECT * FROM projects WHERE organization_id = ? ORDER BY rowid; + +-- name: CanvasGetProjectBySlug :one +SELECT * FROM projects WHERE organization_id = ? AND slug = ?; + +-- name: CanvasInsertProject :exec +INSERT INTO projects (id, organization_id, name, slug, created_at) VALUES (?, ?, ?, ?, ?); + +-- name: CanvasListEnvironments :many +SELECT * FROM environments WHERE project_id = ? ORDER BY rowid; + +-- name: CanvasInsertEnvironment :exec +INSERT INTO environments (id, project_id, name, is_production, created_at) VALUES (?, ?, ?, ?, ?); + +-- name: CanvasGetClusterServers :many +SELECT servers FROM cluster WHERE id = 1; + +-- name: CanvasMarkDirty :exec +UPDATE nodes SET dirty = 1 WHERE id = ?; + +-- name: CanvasListVariables :many +SELECT * FROM variables WHERE node_id = ? ORDER BY rowid; + +-- name: CanvasListEnvironmentVariables :many +SELECT v.* FROM variables v JOIN nodes n ON n.id = v.node_id +WHERE n.environment_id = ? ORDER BY v.rowid; + +-- name: CanvasInsertVariable :exec +INSERT INTO variables (id, node_id, key, value, secret) VALUES (?, ?, ?, ?, ?); + +-- name: CanvasUpdateVariable :execrows +UPDATE variables SET key = ?2, value = ?3, secret = ?4 WHERE id = ?1; + +-- name: CanvasDeleteVariable :exec +DELETE FROM variables WHERE id = ?; + +-- name: CanvasDeleteNodeVariables :exec +DELETE FROM variables WHERE node_id = ?; diff --git a/internal/app/environments.go b/internal/app/environments.go new file mode 100644 index 0000000..95844c3 --- /dev/null +++ b/internal/app/environments.go @@ -0,0 +1,48 @@ +package app + +import ( + "context" + + "github.com/ThallesP/keel/internal/domain" +) + +// EnvironmentSummary is the Ship button's and status bar's numbers (convex/environments.ts summary). +type EnvironmentSummary struct { + // Deployable nodes with a staged change: "Ship · N changes". + PendingChanges int + // Derived status per node, groups excluded (volumes count as pending). Zero counts are absent. + Counts map[domain.NodeStatus]int + // Ready Swarm nodes, install-wide. + Servers int +} + +// EnvironmentSummaryOf is nil when the environment is missing or not the actor's. +func (a *App) EnvironmentSummaryOf(ctx context.Context, actor domain.Actor, environmentID string) (*EnvironmentSummary, error) { + var out *EnvironmentSummary + err := a.read(ctx, func(tx Tx) error { + _, ok, err := ownedEnvironment(tx, actor, environmentID) + if err != nil || !ok { + return err + } + nodes, err := tx.Nodes(environmentID) + if err != nil { + return err + } + s := &EnvironmentSummary{Counts: map[domain.NodeStatus]int{}} + for _, n := range nodes { + if n.Type == domain.NodeGroup { + continue + } + if n.Dirty && n.Type.Deployable() { + s.PendingChanges++ + } + s.Counts[domain.DeriveStatus(n)]++ + } + if s.Servers, err = tx.CanvasClusterServers(); err != nil { + return err + } + out = s + return nil + }) + return out, err +} diff --git a/internal/app/nodes.go b/internal/app/nodes.go new file mode 100644 index 0000000..6b18cde --- /dev/null +++ b/internal/app/nodes.go @@ -0,0 +1,597 @@ +package app + +import ( + "context" + "errors" + + "github.com/ThallesP/keel/internal/domain" +) + +// Canvas nodes (convex/nodes.ts, docs/go/spec/projects.md §3, §9.3, §10). Expose / unexpose are +// the ingress area's; Ship is the deploy area's. + +// ListNodes is the canvas: every node of the environment in creation order (groups included), +// with its endpoints. Empty when the environment is missing or not the actor's. +func (a *App) ListNodes(ctx context.Context, actor domain.Actor, environmentID string) ([]domain.Node, error) { + out := []domain.Node{} + err := a.read(ctx, func(tx Tx) error { + _, ok, err := ownedEnvironment(tx, actor, environmentID) + if err != nil || !ok { + return err + } + out, err = tx.Nodes(environmentID) + return err + }) + return out, err +} + +// CreateNodeInput is nodes.create's arguments. +type CreateNodeInput struct { + Type domain.NodeType + // "" = named after what it runs (engine, image repo) or its type, made unique. + Name string + // nil = right of the rightmost top-level node (the CLI has no canvas to drop it on). + Position *domain.Position + // Services only: the image to run. Non-nil even when empty: "" is a bad image, not "none". + Image *string + // Databases and caches: picks image and port. + Engine domain.Engine + // Deployable types: instead of the engine's/type's port and 1 replica. JSON numbers, so 80.5 + // gets the port message rather than a decoding error. + Port, Replicas *float64 + // Ship right away. Skipped silently when a deployment is already running (the node stays dirty). + Deploy bool +} + +// CreatedNode: DeploymentID is "" when no deployment started. +type CreatedNode struct { + ID string + DeploymentID string +} + +// CreateNode adds a node to the canvas. Checks run in the Convex order; the first failure wins. +func (a *App) CreateNode(ctx context.Context, actor domain.Actor, environmentID string, in CreateNodeInput) (CreatedNode, error) { + var out CreatedNode + err := a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireEnvironment(tx, actor, environmentID) + if err != nil { + return err + } + siblings, err := tx.Nodes(environmentID) + if err != nil { + return err + } + def, ok := domain.NodeDefaults[in.Type] + if !ok { + return domain.Invalid("Unknown node type") + } + runtime := def.Image != "" + if in.Image != nil && in.Type != domain.NodeService { + return domain.Invalid("Only services take a custom image") + } + if (in.Port != nil || in.Replicas != nil) && !runtime { + return domain.Invalid("This node type has no runtime settings") + } + var picked *domain.EngineSpec + if in.Engine != "" { + spec, ok := domain.Engines[in.Engine] + if !ok || spec.Type != in.Type { + return domain.Invalid("%s is not a %s", in.Engine, in.Type) + } + picked = &spec + } + image := "" + switch { + case picked != nil: + image = picked.Image + case in.Image != nil: + if err := domain.ValidImage(*in.Image); err != nil { + return err + } + image = *in.Image + } + // Named after what runs (`nginx`, `api-server`), not the node kind (`service`). + runs := image + if runs == "" { + runs = def.Image + } + base := string(in.Engine) + if base == "" { + base = def.Name + if runs != "" { + base = domain.NameFromImage(runs, def.Name) + } + } + taken := canvasNames(siblings) + if in.Name != "" && taken[in.Name] { + return canvasNameTaken(in.Name) + } + name := in.Name + if name != "" { + if err := domain.ValidName(name); err != nil { + return err + } + } else { + name = domain.UniqueName(base, taken) + } + var desired *domain.Desired + if runtime { + replicas, err := domain.ReplicasNumber(in.Replicas) + if err != nil { + return err + } + port, err := domain.PortNumber(in.Port) + if err != nil { + return err + } + d := &domain.Desired{Image: image, Replicas: 1, Port: port} + if d.Image == "" { + d.Image = def.Image + } + if replicas != nil { + d.Replicas = *replicas + } + if d.Port == nil { + p := def.Port + if picked != nil { + p = picked.Port + } + d.Port = &p + } + desired = d + } + pos := domain.NextPosition(siblings) + if in.Position != nil { + pos = *in.Position + } + node := domain.Node{ + ID: domain.NewID(), + EnvironmentID: environmentID, + Type: in.Type, + Name: name, + Position: pos, + Config: domain.DefaultConfig(in.Type), + Desired: desired, + Dirty: in.Type.Deployable(), + CreatedAt: canvasCreatedAt(a.Now(), siblings), + } + if err := tx.CanvasInsertNode(node); err != nil { + if errors.Is(err, ErrCanvasTaken) { + return canvasNameTaken(name) + } + return err + } + if in.Type == domain.NodeDatabase || in.Type == domain.NodeCache { + for _, v := range domain.SeedVariables(domain.EngineOf(desired.Image)) { + v.ID, v.NodeID = domain.NewID(), node.ID + if err := tx.CanvasInsertVariable(v); err != nil { + return err + } + } + } + if err := canvasTouch(tx, ch, scope.Org, environmentID); err != nil { + return err + } + out.ID = node.ID + if in.Deploy && in.Type.Deployable() { + id, err := canvasShip(a, tx, ch, scope, ShipOptions{Only: []string{node.ID}}) + var refused *domain.Error + switch { + case errors.As(err, &refused): + // A deployment is already running: the node stays dirty, no deploymentId. + case err != nil: + return err + default: + out.DeploymentID = id + } + } + return nil + }) + if err != nil { + return CreatedNode{}, err + } + return out, nil +} + +// NodeUpdate is PATCH /api/nodes/{id}: every field optional, applied in this order in one +// transaction (any failure changes nothing). +type NodeUpdate struct { + // Rename (convex nodes.rename): references to the node follow; not a staged change. + Name *string + // Runtime (convex nodes.setDesired): always a staged change, also for the referrers. + Image *string + Port, Replicas *float64 + // Size of a volume, box of a group. Fields left nil keep their value. Not staged. + Config *domain.NodeConfig + // Group membership: a group's id, or "" for the top level. The node keeps its place on the + // canvas (its position is converted) unless Position is given too. + ParentID *string + // Position (relative to the parent) to set along with ParentID; a plain move is MoveNode. + Position *domain.Position +} + +func (u NodeUpdate) runtime() bool { return u.Image != nil || u.Port != nil || u.Replicas != nil } + +// UpdateNode applies a NodeUpdate. +func (a *App) UpdateNode(ctx context.Context, actor domain.Actor, id string, u NodeUpdate) error { + return a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) + if err != nil { + return err + } + node, changed := scope.Node, false + if u.Name != nil { + renamed, err := canvasRename(tx, node, *u.Name) + if err != nil { + return err + } + changed = changed || renamed.Name != node.Name + node = renamed + } + if u.runtime() { + if node, err = canvasSetDesired(tx, ch, scope.Org, node, u); err != nil { + return err + } + changed = true + } + if u.Config != nil { + if node, err = canvasSetConfig(tx, node, *u.Config); err != nil { + return err + } + changed = true + } + if (u.ParentID != nil && *u.ParentID != node.ParentID) || u.Position != nil { + if node, err = canvasSetParent(tx, node, u.ParentID, u.Position); err != nil { + return err + } + changed = true + } + if !changed { + return nil + } + return canvasTouch(tx, ch, scope.Org, node.EnvironmentID) + }) +} + +// canvasRename: same name is a no-op (before validation); references to the node are rewritten +// (`${{ old.KEY }}` → `${{ new.KEY }}`). Endpoints keep their domain; OTEL_SERVICE_NAME follows +// on the node's next ship. +func canvasRename(tx Tx, node domain.Node, name string) (domain.Node, error) { + if name == node.Name { + return node, nil + } + if err := domain.ValidName(name); err != nil { + return node, err + } + nodes, err := tx.Nodes(node.EnvironmentID) + if err != nil { + return node, err + } + if canvasNames(nodes)[name] { + return node, canvasNameTaken(name) + } + if err := canvasRewriteReferences(tx, node, func(key string) (string, string) { return name, key }); err != nil { + return node, err + } + node.Name = name + if err := tx.CanvasUpdateNode(node); err != nil { + if errors.Is(err, ErrCanvasTaken) { + return node, canvasNameTaken(name) + } + return node, err + } + return node, nil +} + +// canvasSetDesired is convex nodes.setDesired: image, port (cannot be unset), replicas; revision +// and tracing kept; dirty even when nothing changed (dirty is sticky, there is no diff). +func canvasSetDesired(tx Tx, ch *Changes, org string, node domain.Node, u NodeUpdate) (domain.Node, error) { + if node.Desired == nil { + return node, domain.Invalid("This node type has no runtime settings") + } + d := *node.Desired + if u.Image != nil { + if err := domain.ValidImage(*u.Image); err != nil { + return node, err + } + d.Image = *u.Image + } + port, err := domain.PortNumber(u.Port) + if err != nil { + return node, err + } + if port != nil { + d.Port = port + } + replicas, err := domain.ReplicasNumber(u.Replicas) + if err != nil { + return node, err + } + if replicas != nil { + d.Replicas = *replicas + } + node.Desired, node.Dirty = &d, true + if err := tx.CanvasUpdateNode(node); err != nil { + return node, err + } + return node, markReferrersDirty(tx, ch, org, node) +} + +// canvasSetConfig: volumes have a size, groups a box. Not a staged change: neither reaches Swarm. +func canvasSetConfig(tx Tx, node domain.Node, c domain.NodeConfig) (domain.Node, error) { + positive := func(p *float64) bool { return p == nil || *p > 0 } + if c.SizeGb != nil && node.Type != domain.NodeVolume { + return node, domain.Invalid("Only volumes have a size") + } + if (c.Width != nil || c.Height != nil) && node.Type != domain.NodeGroup { + return node, domain.Invalid("Only groups have a width and height") + } + if !positive(c.SizeGb) { + return node, domain.Invalid("Size must be more than 0 GB") + } + if !positive(c.Width) || !positive(c.Height) { + return node, domain.Invalid("Width and height must be more than 0") + } + cfg := node.Config + if c.SizeGb != nil { + cfg.SizeGb = canvasFloat(*c.SizeGb) + } + if c.Width != nil { + cfg.Width = canvasFloat(*c.Width) + } + if c.Height != nil { + cfg.Height = canvasFloat(*c.Height) + } + node.Config = cfg + return node, tx.CanvasUpdateNode(node) +} + +func canvasFloat(f float64) *float64 { return &f } + +// canvasSetParent moves a node into a group ("" = out of it). Groups do not nest (the dashboard +// renders parents before children by putting groups first). Without an explicit position the node +// keeps its place: positions are relative to the parent. +func canvasSetParent(tx Tx, node domain.Node, parentID *string, pos *domain.Position) (domain.Node, error) { + if parentID != nil && *parentID != node.ParentID { + abs := node.Position + if node.ParentID != "" { + if old, err := tx.Node(node.ParentID); err == nil { + abs = domain.Position{X: old.Position.X + abs.X, Y: abs.Y + old.Position.Y} + } else if !errors.Is(err, ErrNoRow) { + return node, err + } + } + if *parentID == "" { + node.ParentID, node.Position = "", abs + } else { + if node.Type == domain.NodeGroup { + return node, domain.Invalid("Groups cannot be nested") + } + group, err := tx.Node(*parentID) + if errors.Is(err, ErrNoRow) || (err == nil && (group.EnvironmentID != node.EnvironmentID || group.Type != domain.NodeGroup)) { + return node, domain.Invalid("Parent must be a group in the same environment") + } + if err != nil { + return node, err + } + node.ParentID = group.ID + node.Position = domain.Position{X: abs.X - group.Position.X, Y: abs.Y - group.Position.Y} + } + } + if pos != nil { + node.Position = *pos + } + return node, tx.CanvasUpdateNode(node) +} + +// MoveNode writes a canvas position (relative to the node's group when it has one). Any numbers; +// not a staged change. The dashboard applies it optimistically on drag end. +func (a *App) MoveNode(ctx context.Context, actor domain.Actor, id string, pos domain.Position) error { + return a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) + if err != nil { + return err + } + node := scope.Node + node.Position = pos + if err := tx.CanvasUpdateNode(node); err != nil { + return err + } + canvasMoved(ch, scope.Org, node.EnvironmentID) + return nil + }) +} + +// DuplicateNode copies a node next to the original (+40, +40) as `-copy`: same type, +// group, config, runtime (revision 0, so never shipped) and one-shot flag; variables verbatim in +// order (a duplicated database shares the generated passwords). Observation, endpoints and errors +// are not copied. Returns the copy's id. +func (a *App) DuplicateNode(ctx context.Context, actor domain.Actor, id string) (string, error) { + var copyID string + err := a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) + if err != nil { + return err + } + n := scope.Node + if n.Type == domain.NodeGroup { + return domain.Invalid("Groups cannot be duplicated") + } + siblings, err := tx.Nodes(n.EnvironmentID) + if err != nil { + return err + } + base := n.Name + if len(base) > 32 { + base = base[:32] + } + dup := domain.Node{ + ID: domain.NewID(), + EnvironmentID: n.EnvironmentID, + Type: n.Type, + Name: domain.UniqueName(base+"-copy", canvasNames(siblings)), + ParentID: n.ParentID, + Position: domain.Position{X: n.Position.X + 40, Y: n.Position.Y + 40}, + Config: canvasCopyConfig(n.Config), + Dirty: n.Type.Deployable(), + OneShot: n.OneShot, + CreatedAt: canvasCreatedAt(a.Now(), siblings), + } + if n.Desired != nil { + d := *n.Desired + d.Revision = 0 + if d.Port != nil { + p := *d.Port + d.Port = &p + } + dup.Desired = &d + } + if err := tx.CanvasInsertNode(dup); err != nil { + if errors.Is(err, ErrCanvasTaken) { + return canvasNameTaken(dup.Name) + } + return err + } + vars, err := tx.CanvasVariables(n.ID) + if err != nil { + return err + } + for _, v := range vars { + if err := tx.CanvasInsertVariable(domain.Variable{ID: domain.NewID(), NodeID: dup.ID, Key: v.Key, Value: v.Value, Secret: v.Secret}); err != nil { + return err + } + } + copyID = dup.ID + return canvasTouch(tx, ch, scope.Org, n.EnvironmentID) + }) + return copyID, err +} + +func canvasCopyConfig(c domain.NodeConfig) domain.NodeConfig { + cp := func(p *float64) *float64 { + if p == nil { + return nil + } + return canvasFloat(*p) + } + return domain.NodeConfig{SizeGb: cp(c.SizeGb), Width: cp(c.Width), Height: cp(c.Height)} +} + +// StartNode scales a node back to 1 replica (when stopped) and ships it: "deploy " for a +// never-shipped node, else "start ". Always ships, even when already running. A stopped +// 3-replica service comes back with 1. Returns the deployment id. +func (a *App) StartNode(ctx context.Context, actor domain.Actor, id string) (string, error) { + var deploymentID string + err := a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) + if err != nil { + return err + } + node := scope.Node + if node.Desired == nil { + return domain.Invalid("This node type cannot be started") + } + verb := "start" + if node.Desired.Revision == 0 { + verb = "deploy" + } + d := *node.Desired + if d.Replicas == 0 { + d.Replicas = 1 + } + node.Desired, node.Dirty = &d, true + if err := tx.CanvasUpdateNode(node); err != nil { + return err + } + if err := canvasTouch(tx, ch, scope.Org, node.EnvironmentID); err != nil { + return err + } + deploymentID, err = canvasShip(a, tx, ch, scope.EnvScope, ShipOptions{Only: []string{id}, Verb: verb}) + return err + }) + return deploymentID, err +} + +// StopNode scales a node to 0 and ships that ("stop "); the Swarm service stays defined. +// ok is false (and nothing is written) when it already is at 0 replicas. +func (a *App) StopNode(ctx context.Context, actor domain.Actor, id string) (deploymentID string, ok bool, err error) { + err = a.write(ctx, func(tx Tx, ch *Changes) error { + scope, err := requireNode(tx, actor, id) + if err != nil { + return err + } + node := scope.Node + if node.Desired == nil { + return domain.Invalid("This node type cannot be stopped") + } + if node.Desired.Replicas == 0 { + return nil + } + d := *node.Desired + d.Replicas = 0 + node.Desired, node.Dirty = &d, true + if err := tx.CanvasUpdateNode(node); err != nil { + return err + } + if err := canvasTouch(tx, ch, scope.Org, node.EnvironmentID); err != nil { + return err + } + deploymentID, err = canvasShip(a, tx, ch, scope.EnvScope, ShipOptions{Only: []string{id}, Verb: "stop"}) + ok = err == nil + return err + }) + if err != nil { + return "", false, err + } + return deploymentID, ok, nil +} + +// RemoveNode deletes a node with its variables (docs/go/spec/projects.md §10). A missing or +// foreign node is not an error: a multi-select delete races itself. Nodes referencing it become +// staged changes (their references now resolve to ""); its group's children move to the top +// level, keeping their place. After commit: proxy sync if it had endpoints, Swarm service removal +// and an observe (which settles a deployment waiting on it) if it had a runtime. Deployments and +// their steps are kept. +func (a *App) RemoveNode(ctx context.Context, actor domain.Actor, id string) error { + if err := actor.RequireUser(); err != nil { + return err + } + return a.write(ctx, func(tx Tx, ch *Changes) error { + scope, ok, err := ownedNode(tx, actor, id) + if err != nil || !ok { + return err + } + node := scope.Node + // Before its own rows go: referrers are found through them and through its name. + if err := markReferrersDirty(tx, ch, scope.Org, node); err != nil { + return err + } + if err := tx.CanvasDeleteNodeVariables(id); err != nil { + return err + } + siblings, err := tx.Nodes(node.EnvironmentID) + if err != nil { + return err + } + for _, child := range siblings { + if child.ParentID != id { + continue + } + child.ParentID = "" + child.Position = domain.Position{X: node.Position.X + child.Position.X, Y: node.Position.Y + child.Position.Y} + if err := tx.CanvasUpdateNode(child); err != nil { + return err + } + } + // The row goes before the removal is scheduled: an in-flight apply that re-reads the node + // after this commit sees nothing and backs out (removing a service it just created). + if err := tx.DeleteNode(id); err != nil { + return err + } + if err := canvasTouch(tx, ch, scope.Org, node.EnvironmentID, id); err != nil { + return err + } + ch.AfterCommit(func() { canvasAfterRemove(a, node) }) + return nil + }) +} diff --git a/internal/app/nodes_seams.go b/internal/app/nodes_seams.go new file mode 100644 index 0000000..6218e5f --- /dev/null +++ b/internal/app/nodes_seams.go @@ -0,0 +1,76 @@ +package app + +import ( + "github.com/ThallesP/keel/internal/domain" +) + +// Seams the canvas area calls, held in variables so its tests can stand in for the areas that own +// them (auth: joinOrFound; deploy: beginDeployment, ScheduleRemoveService, ScheduleObserve; +// ingress: ScheduleProxySync). Production code never reassigns them. Tests set them through +// nodes_export_test.go. +var ( + canvasJoin = joinOrFound + canvasShip = (*App).beginDeployment + // canvasAfterRemove runs after a node delete commits (docs/go/spec/projects.md §10 step 7). + canvasAfterRemove = func(a *App, n domain.Node) { + if len(n.Endpoints) > 0 { + a.ScheduleProxySync() + } + if n.Desired != nil { + a.ScheduleRemoveService(n.ID) + // Cancels the pending observe and settles a running deployment that waits on the node + // ("