From da18e48d5996d898822378e5974cbe22f9361331 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 17:50:12 +0800 Subject: [PATCH 1/6] feat(deploy): proof-of-concept Maple Cloud self-host on celld Run the Cloud Workers (api, electric-sync, alerting) and the web UI on celld instead of Cloudflare, so a VPS install does not need a CF account. celld cannot TCP-dial Postgres, so Postgres goes through a Neon-style WebSocket proxy; AES-GCM uses Web Crypto because celld node:crypto createCipheriv is inert. This is an experiment toward simpler self-hosting, not a replacement for alchemy deploy or local-mode maple start. --- .env.example | 4 + .gitignore | 2 + apps/alerting/wrangler.celld.jsonc | 31 ++ apps/api/.gitignore | 1 + apps/api/src/platform/Crypto.test.ts | 38 ++ apps/api/src/platform/Crypto.ts | 82 ++-- apps/api/src/platform/DatabasePgLive.ts | 7 +- .../src/platform/pg-connection-scope.test.ts | 17 + apps/api/src/platform/pg-connection-scope.ts | 12 +- .../src/platform/pg-connection-source.test.ts | 77 ++++ apps/api/src/platform/pg-connection-source.ts | 95 ++++- apps/api/src/routes/v2/telemetry.http.test.ts | 29 ++ apps/api/src/routes/v2/telemetry.http.ts | 11 +- .../ClickHouseSchemaApplyWorkflow.run.ts | 7 +- .../InvestigationFanoutWorkflow.run.ts | 2 +- apps/api/wrangler.celld.jsonc | 65 +++ apps/electric-sync/src/worker.ts | 6 +- apps/electric-sync/wrangler.celld.jsonc | 16 + apps/web/src/lib/collections/shape-fetch.ts | 9 +- .../src/lib/services/common/api-base-url.ts | 18 +- .../services/common/api-client-transform.ts | 4 +- .../services/common/electric-sync-url.test.ts | 47 +++ .../lib/services/common/electric-sync-url.ts | 46 +- .../src/lib/services/common/http-client.ts | 4 +- apps/web/wrangler.celld.jsonc | 12 + bun.lock | 3 + deploy/celld-self-host/Caddyfile | 34 ++ deploy/celld-self-host/Caddyfile.host | 34 ++ deploy/celld-self-host/compose.yml | 279 ++++++++++++ .../celld-self-host/docker/Dockerfile.celld | 50 +++ .../celld-self-host/docker/Dockerfile.proxy | 10 + .../docker/entrypoint-celld.sh | 35 ++ deploy/celld-self-host/docker/minio-init.sh | 6 + deploy/celld-self-host/docker/write-vars.sh | 27 ++ deploy/celld-self-host/env.example | 41 ++ deploy/celld-self-host/k8s/alerting.yaml | 55 +++ deploy/celld-self-host/k8s/api.yaml | 79 ++++ deploy/celld-self-host/k8s/caddy.yaml | 63 +++ deploy/celld-self-host/k8s/configmap.yaml | 55 +++ deploy/celld-self-host/k8s/kustomization.yaml | 15 + deploy/celld-self-host/k8s/minio.yaml | 102 +++++ deploy/celld-self-host/k8s/namespace.yaml | 4 + deploy/celld-self-host/k8s/proxy.yaml | 35 ++ deploy/celld-self-host/k8s/sync.yaml | 69 +++ deploy/celld-self-host/k8s/web.yaml | 32 ++ docs/celld-self-host.md | 300 +++++++++++++ packages/db/package.json | 1 + packages/db/src/client.test.ts | 65 +++ packages/db/src/client.ts | 60 +++ packages/db/src/pg-ws-socket.ts | 249 +++++++++++ scripts/celld-dev.sh | 396 ++++++++++++++++++ scripts/pg-ws-proxy.test.ts | 132 ++++++ scripts/pg-ws-proxy.ts | 241 +++++++++++ 53 files changed, 3051 insertions(+), 63 deletions(-) create mode 100644 apps/alerting/wrangler.celld.jsonc create mode 100644 apps/api/src/platform/Crypto.test.ts create mode 100644 apps/api/wrangler.celld.jsonc create mode 100644 apps/electric-sync/wrangler.celld.jsonc create mode 100644 apps/web/src/lib/services/common/electric-sync-url.test.ts create mode 100644 apps/web/wrangler.celld.jsonc create mode 100644 deploy/celld-self-host/Caddyfile create mode 100644 deploy/celld-self-host/Caddyfile.host create mode 100644 deploy/celld-self-host/compose.yml create mode 100644 deploy/celld-self-host/docker/Dockerfile.celld create mode 100644 deploy/celld-self-host/docker/Dockerfile.proxy create mode 100755 deploy/celld-self-host/docker/entrypoint-celld.sh create mode 100755 deploy/celld-self-host/docker/minio-init.sh create mode 100755 deploy/celld-self-host/docker/write-vars.sh create mode 100644 deploy/celld-self-host/env.example create mode 100644 deploy/celld-self-host/k8s/alerting.yaml create mode 100644 deploy/celld-self-host/k8s/api.yaml create mode 100644 deploy/celld-self-host/k8s/caddy.yaml create mode 100644 deploy/celld-self-host/k8s/configmap.yaml create mode 100644 deploy/celld-self-host/k8s/kustomization.yaml create mode 100644 deploy/celld-self-host/k8s/minio.yaml create mode 100644 deploy/celld-self-host/k8s/namespace.yaml create mode 100644 deploy/celld-self-host/k8s/proxy.yaml create mode 100644 deploy/celld-self-host/k8s/sync.yaml create mode 100644 deploy/celld-self-host/k8s/web.yaml create mode 100644 docs/celld-self-host.md create mode 100644 packages/db/src/client.test.ts create mode 100644 packages/db/src/pg-ws-socket.ts create mode 100755 scripts/celld-dev.sh create mode 100644 scripts/pg-ws-proxy.test.ts create mode 100644 scripts/pg-ws-proxy.ts diff --git a/.env.example b/.env.example index 1c140a8ea..4dfb79f3e 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,10 @@ MAPLE_DB_URL= # it into a Hyperdrive origin. PR previews bind no database at all. NB the connect-time database is `postgres` (the cluster default), # NOT the PlanetScale resource name `maple` (that's only for `pscale` commands). # MAPLE_PG_URL=postgres://user:pass@host.pg.psdb.cloud:5432/postgres?sslmode=verify-full +# celld self-host (no Hyperdrive): logical Postgres URL + host-side WS↔TCP proxy. +# Do not put a Postgres URL in MAPLE_DB_URL (that is the PGlite data dir). +# MAPLE_PG_URL=postgres://maple:maple@127.0.0.1:5499/maple +# MAPLE_PG_WS_PROXY=ws://127.0.0.1:5498 # Base64-encoded 32-byte key (AES-256-GCM) used to encrypt private ingest keys at rest MAPLE_INGEST_KEY_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore index aa8af3c41..10c79c415 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ mise.local.toml node_modules .DS_Store +.tools/ +.celld/ dist dist-ssr count.txt diff --git a/apps/alerting/wrangler.celld.jsonc b/apps/alerting/wrangler.celld.jsonc new file mode 100644 index 000000000..3fd542afe --- /dev/null +++ b/apps/alerting/wrangler.celld.jsonc @@ -0,0 +1,31 @@ +{ + // celld-safe subset of wrangler.jsonc. Forbidden keys (`hyperdrive`, `ai`, + // `ratelimits`, `send_email`, `routes`, `dev`, `workers_dev`) stop `celld + // deploy` / `celld dev`. Secrets overlay via CELLD_VARS_FILE — see + // docs/celld-self-host.md and scripts/celld-dev.sh. Do not commit secrets. + // Own project: never `celld deploy` this onto the maple-api fleet. + "name": "maple-alerting", + "main": "src/worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "API_V2_RATE_LIMIT_PARTITION": "local", + "MAPLE_AUTH_MODE": "self_hosted", + "MAPLE_DEFAULT_ORG_ID": "default", + "MAPLE_ENVIRONMENT": "development", + "MAPLE_ALERTING_ALLOW_NONPROD": "1", + "MAPLE_INGEST_PUBLIC_URL": "http://127.0.0.1:3474", + "MAPLE_APP_BASE_URL": "http://127.0.0.1:3471", + "CLICKHOUSE_PROVIDER": "clickhouse", + "CLICKHOUSE_URL": "http://127.0.0.1:8123", + "CLICKHOUSE_USER": "maple", + "CLICKHOUSE_DATABASE": "default", + "MAPLE_PG_URL": "postgres://maple:maple@127.0.0.1:5499/maple", + "MAPLE_PG_WS_PROXY": "ws://127.0.0.1:5498", + "TINYBIRD_HOST": "http://127.0.0.1:7181", + "TINYBIRD_TOKEN": "local-placeholder" + }, + "triggers": { + "crons": ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *"] + } +} diff --git a/apps/api/.gitignore b/apps/api/.gitignore index b3b731385..71f59f245 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -1,3 +1,4 @@ # cold-path measurement bundle output .coldpath-out +.celld/ diff --git a/apps/api/src/platform/Crypto.test.ts b/apps/api/src/platform/Crypto.test.ts new file mode 100644 index 000000000..115dcaf97 --- /dev/null +++ b/apps/api/src/platform/Crypto.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "./Crypto" + +const fail = (message: string) => new Error(message) + +describe("AES-256-GCM via Web Crypto", () => { + it.effect("round-trips plaintext without AAD", () => + Effect.gen(function* () { + const key = yield* parseBase64Aes256GcmKey(Buffer.alloc(32, 7).toString("base64"), fail) + const encrypted = yield* encryptAes256Gcm("maple_sk_test", key, fail) + const plaintext = yield* decryptAes256Gcm(encrypted, key, fail) + assert.strictEqual(plaintext, "maple_sk_test") + assert.isTrue(encrypted.iv.length > 0) + assert.isTrue(encrypted.tag.length > 0) + }), + ) + + it.effect("round-trips with AAD and rejects a mismatched AAD", () => + Effect.gen(function* () { + const key = yield* parseBase64Aes256GcmKey(Buffer.alloc(32, 9).toString("base64"), fail) + const aad = Buffer.from("org:default") + const encrypted = yield* encryptAes256Gcm("secret", key, fail, aad) + const plaintext = yield* decryptAes256Gcm(encrypted, key, fail, aad) + assert.strictEqual(plaintext, "secret") + + const exit = yield* Effect.exit(decryptAes256Gcm(encrypted, key, fail, Buffer.from("org:other"))) + assert.isTrue(exit._tag === "Failure") + }), + ) + + it.effect("rejects a non-32-byte encryption key", () => + Effect.gen(function* () { + const exit = yield* Effect.exit(parseBase64Aes256GcmKey(Buffer.alloc(16, 1).toString("base64"), fail)) + assert.isTrue(exit._tag === "Failure") + }), + ) +}) diff --git a/apps/api/src/platform/Crypto.ts b/apps/api/src/platform/Crypto.ts index b58aed4a8..08c86c7d7 100644 --- a/apps/api/src/platform/Crypto.ts +++ b/apps/api/src/platform/Crypto.ts @@ -1,4 +1,3 @@ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto" import { Effect } from "effect" export interface EncryptedValue { @@ -7,6 +6,25 @@ export interface EncryptedValue { readonly tag: string } +const AES_GCM_TAG_BYTES = 16 + +const toBytes = (value: Buffer | Uint8Array): Uint8Array => + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + +const toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64") + +const fromBase64 = (raw: string): Uint8Array => new Uint8Array(Buffer.from(raw, "base64")) + +const importAesGcmKey = (encryptionKey: Buffer, usage: KeyUsage) => + crypto.subtle.importKey("raw", toBytes(encryptionKey), { name: "AES-GCM" }, false, [usage]) + +const aesGcmParams = (iv: BufferSource, aad: Buffer | undefined): AesGcmParams => ({ + name: "AES-GCM", + iv, + tagLength: AES_GCM_TAG_BYTES * 8, + ...(aad !== undefined ? { additionalData: toBytes(aad) } : {}), +}) + export const parseBase64Aes256GcmKey = (raw: string, onError: (message: string) => E) => Effect.try({ try: () => { @@ -32,6 +50,12 @@ export const parseBase64Aes256GcmKey = (raw: string, onError: (message: strin * the original (AAD-free) format — existing ciphertexts written without one must * keep decrypting, so callers may only start passing an `aad` for columns with * no live rows. + * + * Implemented with Web Crypto (`crypto.subtle`) rather than `node:crypto` + * `createCipheriv`. celld/workerd's nodejs_compat HMAC works; AES-GCM through + * `createCipheriv` does not, which 500'd `GET /v2/ingest_keys` on self-host. + * The stored `{ciphertext,iv,tag}` layout is unchanged, so Cloud-written rows + * still decrypt. */ export const encryptAes256Gcm = ( plaintext: string, @@ -39,17 +63,26 @@ export const encryptAes256Gcm = ( onError: (message: string) => E, aad?: Buffer, ) => - Effect.try({ - try: () => { - const iv = randomBytes(12) - const cipher = createCipheriv("aes-256-gcm", encryptionKey, iv) - if (aad !== undefined) cipher.setAAD(aad) - const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]) - + Effect.tryPromise({ + try: async () => { + const iv = crypto.getRandomValues(new Uint8Array(12)) + const key = await importAesGcmKey(encryptionKey, "encrypt") + const bundled = new Uint8Array( + await crypto.subtle.encrypt( + aesGcmParams(iv, aad), + key, + new TextEncoder().encode(plaintext), + ), + ) + if (bundled.byteLength < AES_GCM_TAG_BYTES) { + throw new Error("AES-GCM encrypt returned a truncated payload") + } + const tag = bundled.subarray(bundled.byteLength - AES_GCM_TAG_BYTES) + const ciphertext = bundled.subarray(0, bundled.byteLength - AES_GCM_TAG_BYTES) return { - ciphertext: ciphertext.toString("base64"), - iv: iv.toString("base64"), - tag: cipher.getAuthTag().toString("base64"), + ciphertext: toBase64(ciphertext), + iv: toBase64(iv), + tag: toBase64(tag), } satisfies EncryptedValue }, catch: (error) => onError(error instanceof Error ? error.message : "Encryption failed"), @@ -62,22 +95,17 @@ export const decryptAes256Gcm = ( onError: (message: string) => E, aad?: Buffer, ) => - Effect.try({ - try: () => { - const decipher = createDecipheriv( - "aes-256-gcm", - encryptionKey, - Buffer.from(encrypted.iv, "base64"), - ) - if (aad !== undefined) decipher.setAAD(aad) - decipher.setAuthTag(Buffer.from(encrypted.tag, "base64")) - - const plaintext = Buffer.concat([ - decipher.update(Buffer.from(encrypted.ciphertext, "base64")), - decipher.final(), - ]) - - return plaintext.toString("utf8") + Effect.tryPromise({ + try: async () => { + const iv = fromBase64(encrypted.iv) + const ciphertext = fromBase64(encrypted.ciphertext) + const tag = fromBase64(encrypted.tag) + const bundled = new Uint8Array(ciphertext.byteLength + tag.byteLength) + bundled.set(ciphertext, 0) + bundled.set(tag, ciphertext.byteLength) + const key = await importAesGcmKey(encryptionKey, "decrypt") + const plaintext = await crypto.subtle.decrypt(aesGcmParams(iv, aad), key, bundled) + return new TextDecoder().decode(plaintext) }, catch: () => onError("Decryption failed"), }) diff --git a/apps/api/src/platform/DatabasePgLive.ts b/apps/api/src/platform/DatabasePgLive.ts index f11450227..626e20bbf 100644 --- a/apps/api/src/platform/DatabasePgLive.ts +++ b/apps/api/src/platform/DatabasePgLive.ts @@ -24,7 +24,12 @@ const makePgDatabase = Effect.gen(function* () { execute: (fn: (db: DatabaseClient) => Promise) => Effect.flatMap(PgConnectionScope, (scope) => scope === undefined - ? executeOnFreshPgClient(source.connectionString, fn, source.attributes) + ? executeOnFreshPgClient( + source.connectionString, + fn, + source.attributes, + source.wsProxyUrl, + ) : scope.run(fn), ), } satisfies DatabaseApi) diff --git a/apps/api/src/platform/pg-connection-scope.test.ts b/apps/api/src/platform/pg-connection-scope.test.ts index 251dbeb65..1e5c10e8f 100644 --- a/apps/api/src/platform/pg-connection-scope.test.ts +++ b/apps/api/src/platform/pg-connection-scope.test.ts @@ -235,6 +235,23 @@ describe("PgConnectionScope", () => { }), ) + it.effect("forwards the websocket proxy URL to the socket factory", () => + Effect.gen(function* () { + const rec = recorder() + const scope = makePgConnectionScope( + "postgres://unused", + undefined, + { openSocket: rec.openSocket }, + "ws://127.0.0.1:5498", + ) + + yield* scope.run(noop) + + assert.strictEqual(rec.lastOptions()?.wsProxyUrl, "ws://127.0.0.1:5498") + yield* Effect.promise(() => scope.close()) + }), + ) + it.effect("closes twice without opening a second connection", () => Effect.gen(function* () { const rec = recorder() diff --git a/apps/api/src/platform/pg-connection-scope.ts b/apps/api/src/platform/pg-connection-scope.ts index 433ca887c..f0e002bb1 100644 --- a/apps/api/src/platform/pg-connection-scope.ts +++ b/apps/api/src/platform/pg-connection-scope.ts @@ -117,10 +117,12 @@ export const makePgConnectionScope = ( connectionString: string, extraAttributes?: Record, seams?: PgConnectionScopeSeams, + wsProxyUrl?: string, ): PgConnectionScopeApi => { const options: MaplePgSocketOptions = { maxConnections: MAX_CONNECTIONS, connectTimeoutSeconds: CONNECT_TIMEOUT_SECONDS, + ...(wsProxyUrl === undefined ? undefined : { wsProxyUrl }), } const create = seams?.openSocket ?? ((opts: MaplePgSocketOptions) => createMaplePgSocket(connectionString, opts)) @@ -170,7 +172,10 @@ export const makePgConnectionScope = ( // Wrapped per call so each call's statements land in its own span. // One shared wrapper would cross-attribute `db.query.text` between // concurrent calls; the wrapper is cheap (relational config only). - return await fn(wrapMaplePgClient(open.sql, { onQuery: hooks.collect })) + const db = + open.wrapClient?.({ onQuery: hooks.collect }) ?? + wrapMaplePgClient(open.sql, { onQuery: hooks.collect }) + return await fn(db) }, extraAttributes), ) }), @@ -213,9 +218,10 @@ export const executeOnFreshPgClient = ( connectionString: string, fn: (db: DatabaseClient) => Promise, extraAttributes?: Record, + wsProxyUrl?: string, ): Effect.Effect => Effect.suspend(() => { - const scope = makePgConnectionScope(connectionString, extraAttributes) + const scope = makePgConnectionScope(connectionString, extraAttributes, undefined, wsProxyUrl) // Never let a socket-teardown error shadow the real DB error from fn(db). return scope.run(fn).pipe(Effect.ensuring(Effect.promise(() => scope.close()))) }) @@ -251,7 +257,7 @@ export const withPgConnectionScope = ( if (source._tag === "Unavailable") return yield* program return yield* withPgConnectionScopeOf( - makePgConnectionScope(source.connectionString, source.attributes), + makePgConnectionScope(source.connectionString, source.attributes, undefined, source.wsProxyUrl), program, ) }) diff --git a/apps/api/src/platform/pg-connection-source.test.ts b/apps/api/src/platform/pg-connection-source.test.ts index 8e8632804..ca3473291 100644 --- a/apps/api/src/platform/pg-connection-source.test.ts +++ b/apps/api/src/platform/pg-connection-source.test.ts @@ -56,4 +56,81 @@ describe("resolveDbConnectionSource", () => { expect(source._tag).toBe("Unavailable") }) + + it("synthesizes a connection from MAPLE_PG_URL without leaking credentials", () => { + const source = resolveDbConnectionSource({ + MAPLE_PG_URL: "postgres://maple:s3cret@127.0.0.1:5499/maple", + }) + + expect(source).toStrictEqual({ + _tag: "Available", + connectionString: "postgres://maple:s3cret@127.0.0.1:5499/maple", + attributes: { + "db.namespace": "maple", + "server.address": "127.0.0.1", + "server.port": 5499, + }, + }) + const serialized = JSON.stringify(source._tag === "Available" ? source.attributes : {}) + expect(serialized).not.toContain("s3cret") + }) + + it("attaches MAPLE_PG_WS_PROXY only on the MAPLE_PG_URL path", () => { + const source = resolveDbConnectionSource({ + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + MAPLE_PG_WS_PROXY: "ws://127.0.0.1:5498", + }) + + expect(source._tag).toBe("Available") + expect(source._tag === "Available" && source.wsProxyUrl).toBe("ws://127.0.0.1:5498") + }) + + it("ignores MAPLE_PG_WS_PROXY that is not a websocket URL", () => { + const source = resolveDbConnectionSource({ + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + MAPLE_PG_WS_PROXY: "http://127.0.0.1:5498", + }) + + expect(source._tag).toBe("Available") + expect(source._tag === "Available" && source.wsProxyUrl).toBeUndefined() + }) + + it("ignores MAPLE_PG_URL when a Hyperdrive binding is present", () => { + const source = resolveDbConnectionSource({ + [HYPERDRIVE_BINDING]: hyperdriveBinding, + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + MAPLE_PG_WS_PROXY: "ws://127.0.0.1:5498", + }) + + expect(source).toStrictEqual({ + _tag: "Available", + connectionString: hyperdriveBinding.connectionString, + attributes: { + "db.namespace": hyperdriveBinding.database, + "server.address": hyperdriveBinding.host, + "server.port": hyperdriveBinding.port, + }, + }) + }) + + it("falls through to MAPLE_PG_URL when MAPLE_DB is a string rather than a Hyperdrive object", () => { + const source = resolveDbConnectionSource({ + [HYPERDRIVE_BINDING]: "postgres://maple:maple@127.0.0.1:5499/maple", + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + }) + + expect(source._tag).toBe("Available") + expect(source._tag === "Available" && source.connectionString).toBe( + "postgres://maple:maple@127.0.0.1:5499/maple", + ) + }) + + it.each(["not-a-url", "http://127.0.0.1:5499/maple", "postgres://"])( + "reports unavailable when MAPLE_PG_URL is %s", + (pgUrl) => { + const source = resolveDbConnectionSource({ MAPLE_PG_URL: pgUrl }) + + expect(source._tag).toBe("Unavailable") + }, + ) }) diff --git a/apps/api/src/platform/pg-connection-source.ts b/apps/api/src/platform/pg-connection-source.ts index 6d09327b8..e1f3e0b0f 100644 --- a/apps/api/src/platform/pg-connection-source.ts +++ b/apps/api/src/platform/pg-connection-source.ts @@ -2,9 +2,17 @@ * Pure resolver shared by request and Workflow paths. Keep Workers on * Hyperdrive: request-scoped sockets make direct PSBouncer connections pay a * handshake per execute (measured 679ms + 158ms versus Hyperdrive's 11ms + 14ms). + * + * celld has no Hyperdrive binding. `MAPLE_PG_URL` synthesizes the same + * `{ connectionString, host, port, database }` shape; `MAPLE_PG_WS_PROXY` is the + * Neon-compatible WebSocket proxy `@neondatabase/serverless` uses instead of TCP. + * A string `MAPLE_DB` is still Unavailable on purpose — that binding is an object + * or it is absent. */ export const HYPERDRIVE_BINDING = "MAPLE_DB" +export const MAPLE_PG_URL_VAR = "MAPLE_PG_URL" +export const MAPLE_PG_WS_PROXY_VAR = "MAPLE_PG_WS_PROXY" export type DbConnectionSource = | { @@ -12,6 +20,8 @@ export type DbConnectionSource = readonly connectionString: string /** Never contains credentials. */ readonly attributes: Record + /** Host-side `scripts/pg-ws-proxy.ts` (`/v1` Neon pipe). Unset = postgres.js TCP (wrangler). */ + readonly wsProxyUrl?: string } | { readonly _tag: "Unavailable"; readonly reason: string } @@ -34,24 +44,85 @@ const isHyperdriveBinding = (value: unknown): value is HyperdriveBindingContract ) } +const readNonEmptyString = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +const parsePostgresUrl = ( + raw: string, +): + | { + readonly connectionString: string + readonly host: string + readonly port: number + readonly database: string + } + | undefined => { + let url: URL + try { + url = new URL(raw) + } catch { + return undefined + } + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return undefined + const host = url.hostname + if (host.length === 0) return undefined + const port = url.port.length === 0 ? 5432 : Number(url.port) + if (!Number.isFinite(port) || port <= 0) return undefined + const path = decodeURIComponent(url.pathname.replace(/^\/+/, "")) + const database = path.split("/")[0] ?? "" + return { + connectionString: raw, + host, + port, + database: database.length > 0 ? database : "postgres", + } +} + +const readWsProxyUrl = (value: unknown): string | undefined => { + const raw = readNonEmptyString(value) + if (raw === undefined) return undefined + if (!raw.startsWith("ws://") && !raw.startsWith("wss://")) return undefined + return raw +} + export const resolveDbConnectionSource = (env: Record): DbConnectionSource => { const binding = env[HYPERDRIVE_BINDING] - if (!isHyperdriveBinding(binding)) { - // PR previews intentionally omit this binding. + if (isHyperdriveBinding(binding)) { return { - _tag: "Unavailable", - reason: `No application database on this stage (${HYPERDRIVE_BINDING} binding absent)`, + _tag: "Available", + connectionString: binding.connectionString, + attributes: { + // The read path normalizes Hyperdrive's opaque host/database to its sentinel node. + "db.namespace": binding.database, + "server.address": binding.host, + "server.port": binding.port, + }, + } + } + + const pgUrl = readNonEmptyString(env[MAPLE_PG_URL_VAR]) + if (pgUrl !== undefined) { + const parsed = parsePostgresUrl(pgUrl) + if (parsed !== undefined) { + const wsProxyUrl = readWsProxyUrl(env[MAPLE_PG_WS_PROXY_VAR]) + return { + _tag: "Available", + connectionString: parsed.connectionString, + attributes: { + "db.namespace": parsed.database, + "server.address": parsed.host, + "server.port": parsed.port, + }, + ...(wsProxyUrl === undefined ? undefined : { wsProxyUrl }), + } } } return { - _tag: "Available", - connectionString: binding.connectionString, - attributes: { - // The read path normalizes Hyperdrive's opaque host/database to its sentinel node. - "db.namespace": binding.database, - "server.address": binding.host, - "server.port": binding.port, - }, + _tag: "Unavailable", + reason: `No application database on this stage (${HYPERDRIVE_BINDING} binding absent)`, } } diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 213e1dcba..4c3231fa7 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -597,6 +597,35 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) + it("sends second-precision window bounds to trace_list_mv search", async () => { + const observedSql: string[] = [] + const observingWarehouse: WarehouseQueryServiceApi = { + ...warehouseStub, + compiledQuery: (tenant, compiled, options) => { + observedSql.push(compiledQueryOf(compiled).sql) + return warehouseStub.compiledQuery(tenant, compiled, options) + }, + compiledQueryFirst: (tenant, compiled, options) => { + observedSql.push(compiledQueryOf(compiled).sql) + return warehouseStub.compiledQueryFirst(tenant, compiled, options) + }, + } + const harness = makeHarness(observingWarehouse) + const key = await harness.bootstrapKey() + + const traces = await harness.request("POST", "/v2/traces/search", key.secret, { + start_time: "2026-07-15T12:00:00.900Z", + end_time: "2026-07-15T13:00:00.100Z", + }) + expect(traces.status).toBe(200) + const sql = observedSql.find((statement) => statement.includes("FROM trace_list_mv")) + expect(sql).toBeDefined() + expect(sql).toContain("'2026-07-15 12:00:00'") + expect(sql).toContain("'2026-07-15 13:00:00'") + expect(sql).not.toMatch(/'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+'/) + await harness.dispose() + }) + it("enforces signal query windows, bucket budgets, and breakdown narrowing", async () => { const harness = makeHarness() const key = await harness.bootstrapKey(["traces:read"]) diff --git a/apps/api/src/routes/v2/telemetry.http.ts b/apps/api/src/routes/v2/telemetry.http.ts index 3bbc07b29..c2fc002c4 100644 --- a/apps/api/src/routes/v2/telemetry.http.ts +++ b/apps/api/src/routes/v2/telemetry.http.ts @@ -500,7 +500,11 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand const window = yield* parseWindow(payload.start_time, payload.end_time, { maxSeconds: MAX_SEARCH_RANGE_SECONDS, rangeLabel: "Trace search", - precision: "millisecond", + // `traceSummariesQuery` reads `trace_list_mv.Timestamp`, a plain + // DateTime. Millisecond bounds are a TYPE_MISMATCH on vanilla CH + // (`Cannot convert string '….000' to type DateTime`). Raw `traces` + // is DateTime64; this listing is not. + precision: "second", }) const limit = payload.limit ?? 20 const cursorParts = yield* decodeKeysetCursor(payload.cursor, "trc", 2) @@ -554,7 +558,8 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand const window = yield* parseWindow(payload.start_time, payload.end_time, { maxSeconds: MAX_QUERY_RANGE_SECONDS, rangeLabel: "Trace timeseries", - precision: "millisecond", + // Rollup splice (`service_overview_*`) stores DateTime Hour/Minute. + precision: "second", }) const bucketSeconds = yield* validateTimeseriesBucket( payload.start_time, @@ -604,7 +609,7 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand const window = yield* parseWindow(payload.start_time, payload.end_time, { maxSeconds: MAX_BREAKDOWN_RANGE_SECONDS, rangeLabel: "Trace breakdown", - precision: "millisecond", + precision: "second", }) yield* validateBreakdownRange(window.rangeSeconds, payload.filters) const request = yield* decodeQueryEngineRequest( diff --git a/apps/api/src/workflows/ClickHouseSchemaApplyWorkflow.run.ts b/apps/api/src/workflows/ClickHouseSchemaApplyWorkflow.run.ts index 7b73b60be..0933fc5ae 100644 --- a/apps/api/src/workflows/ClickHouseSchemaApplyWorkflow.run.ts +++ b/apps/api/src/workflows/ClickHouseSchemaApplyWorkflow.run.ts @@ -379,7 +379,12 @@ export async function runClickHouseSchemaApply( if (source._tag === "Unavailable") { throw new Error(source.reason) } - const connection = makePgConnectionScope(source.connectionString, source.attributes) + const connection = makePgConnectionScope( + source.connectionString, + source.attributes, + undefined, + source.wsProxyUrl, + ) try { return await runWithDb(connection, env, event, step) } finally { diff --git a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts b/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts index 6d1ee06f1..d91fff9c9 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts +++ b/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts @@ -625,7 +625,7 @@ const dialWorkflowDb = (env: InvestigationFanoutWorkflowEnv): PgConnectionScopeA if (source._tag === "Unavailable") { throw new Error(source.reason) } - return makePgConnectionScope(source.connectionString, source.attributes) + return makePgConnectionScope(source.connectionString, source.attributes, undefined, source.wsProxyUrl) } async function runWithDb( diff --git a/apps/api/wrangler.celld.jsonc b/apps/api/wrangler.celld.jsonc new file mode 100644 index 000000000..cd7e17878 --- /dev/null +++ b/apps/api/wrangler.celld.jsonc @@ -0,0 +1,65 @@ +{ + // celld-safe subset of wrangler.jsonc. Forbidden keys (`hyperdrive`, `ai`, + // `ratelimits`, `send_email`, `routes`, `dev`, `workers_dev`) stop `celld + // deploy` / `celld dev`. Secrets overlay via CELLD_VARS_FILE — see + // docs/celld-self-host.md and scripts/celld-dev.sh. Do not commit secrets. + "name": "maple-api", + "main": "src/worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "API_V2_RATE_LIMIT_PARTITION": "local", + "PLANETSCALE_WEBHOOK_QUEUE_NAME": "maple-planetscale-webhooks-local", + "VCS_SYNC_QUEUE_NAME": "maple-vcs-sync-local", + "MAPLE_AUTH_MODE": "self_hosted", + "MAPLE_DEFAULT_ORG_ID": "default", + "MAPLE_ENVIRONMENT": "development", + "MAPLE_INGEST_PUBLIC_URL": "http://127.0.0.1:3474", + "MAPLE_APP_BASE_URL": "http://127.0.0.1:3471", + "CLICKHOUSE_PROVIDER": "clickhouse", + "CLICKHOUSE_URL": "http://127.0.0.1:8123", + "CLICKHOUSE_USER": "maple", + "CLICKHOUSE_DATABASE": "default", + "MAPLE_PG_URL": "postgres://maple:maple@127.0.0.1:5499/maple", + "MAPLE_PG_WS_PROXY": "ws://127.0.0.1:5498", + "TINYBIRD_HOST": "http://127.0.0.1:7181", + "TINYBIRD_TOKEN": "local-placeholder" + }, + "triggers": { + "crons": ["0 */12 * * *", "0 * * * *", "0 */6 * * *"] + }, + "durable_objects": { + "bindings": [ + { + "name": "CHAT_SESSION", + "class_name": "ChatSession" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ChatSession"] + } + ], + "kv_namespaces": [ + { + "binding": "MCP_SESSIONS", + "id": "00000000000000000000000000000000" + } + ], + // Queues omitted: celld refuses a queue consumer on a Worker that also + // exports fetch(). Existing queue code no-ops without the binding. + "workflows": [ + { + "name": "clickhouse-schema-apply-workflow", + "binding": "CLICKHOUSE_SCHEMA_APPLY_WORKFLOW", + "class_name": "ClickHouseSchemaApplyWorkflow" + }, + { + "name": "investigation-fanout-workflow", + "binding": "INVESTIGATION_FANOUT_WORKFLOW", + "class_name": "InvestigationFanoutWorkflow" + } + ] +} diff --git a/apps/electric-sync/src/worker.ts b/apps/electric-sync/src/worker.ts index 622d72d64..83a7985e9 100644 --- a/apps/electric-sync/src/worker.ts +++ b/apps/electric-sync/src/worker.ts @@ -87,7 +87,11 @@ const buildHandler = async () => { HttpRouter.cors({ allowedOrigins: ["*"], allowedMethods: ["GET", "OPTIONS"], - allowedHeaders: ["*"], + // `Authorization` is excluded from the `*` wildcard by the Fetch + // spec. Listing only `*` makes Chrome drop the credentialed + // preflight (`Access-Control-Allow-Headers: *` cannot authorize + // the Authorization request header). Same pairing as apps/api. + allowedHeaders: ["*", "Authorization"], // Load-bearing, not hygiene: the electric-* headers must be // readable cross-origin or @electric-sql/client cannot advance // the shape cursor (handle/offset/up-to-date) through the proxy, diff --git a/apps/electric-sync/wrangler.celld.jsonc b/apps/electric-sync/wrangler.celld.jsonc new file mode 100644 index 000000000..88dc5abf4 --- /dev/null +++ b/apps/electric-sync/wrangler.celld.jsonc @@ -0,0 +1,16 @@ +{ + // celld-safe subset of wrangler.jsonc. Forbidden keys (`hyperdrive`, `ai`, + // `ratelimits`, `send_email`, `routes`, `dev`, `workers_dev`) stop `celld + // deploy` / `celld dev`. Secrets overlay via CELLD_VARS_FILE — see + // docs/celld-self-host.md and scripts/celld-dev.sh. Do not commit secrets. + // Own project: never `celld deploy` this onto the maple-api fleet. + "name": "maple-electric-sync", + "main": "src/worker.ts", + "compatibility_date": "2026-04-08", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "ELECTRIC_URL": "http://127.0.0.1:3473", + "MAPLE_AUTH_MODE": "self_hosted", + "MAPLE_DEFAULT_ORG_ID": "default" + } +} diff --git a/apps/web/src/lib/collections/shape-fetch.ts b/apps/web/src/lib/collections/shape-fetch.ts index b30cb5aa5..863264049 100644 --- a/apps/web/src/lib/collections/shape-fetch.ts +++ b/apps/web/src/lib/collections/shape-fetch.ts @@ -5,7 +5,7 @@ import { } from "@maple/effect-db/electric" import type { ManagedRuntime, Schema } from "effect" import { mapleRuntime } from "@/lib/registry" -import { electricSyncBaseUrl } from "@/lib/services/common/electric-sync-url" +import { getSyncProxyUrl } from "@/lib/services/common/electric-sync-url" import { getMapleAuthHeaders } from "@/lib/services/common/auth-headers" import { tracedFetch } from "@/lib/services/common/telemetry" @@ -14,8 +14,11 @@ import { tracedFetch } from "@/lib/services/common/telemetry" * collection points its ShapeStream here with `?shape=`; the proxy * authenticates, injects the org scope, and forwards to Electric. Never point a * ShapeStream at Electric directly — it has no auth. + * + * Resolved at collection-create time so production same-origin (`VITE_ELECTRIC_SYNC_URL=""`) + * becomes `location.origin/api/sync/shape`. ShapeStream cannot take a relative URL. */ -export const syncProxyUrl = `${electricSyncBaseUrl}/api/sync/shape` +export const syncProxyUrl = getSyncProxyUrl /** * `fetchClient` for every ShapeStream. Mirrors `mapleFetch` in http-client.ts @@ -86,7 +89,7 @@ export const createSyncedCollection = >(config: { schema: config.schema, getKey: config.getKey, shapeOptions: { - url: syncProxyUrl, + url: syncProxyUrl(), params: { shape: config.shape, ...(config.scope ? { scope: config.scope } : undefined) }, fetchClient: mapleSyncFetch, ...(config.parser ? { parser: config.parser } : undefined), diff --git a/apps/web/src/lib/services/common/api-base-url.ts b/apps/web/src/lib/services/common/api-base-url.ts index 0cc24430f..a29250cc3 100644 --- a/apps/web/src/lib/services/common/api-base-url.ts +++ b/apps/web/src/lib/services/common/api-base-url.ts @@ -1,6 +1,22 @@ const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim() +/** + * Empty string = same origin (production behind Caddy). Dev defaults to the + * local API port. `""` must not be used with `url.startsWith(apiBaseUrl)` — + * every URL starts with empty; use {@link isMapleApiRequestUrl}. + */ export const apiBaseUrl = configuredApiBaseUrl && configuredApiBaseUrl.length > 0 ? configuredApiBaseUrl.replace(/\/$/, "") - : "http://127.0.0.1:3472" + : import.meta.env.DEV + ? "http://127.0.0.1:3472" + : "" + +export const isMapleApiRequestUrl = (url: string): boolean => { + if (apiBaseUrl.length > 0) return url.startsWith(apiBaseUrl) + if (url.startsWith("/")) return true + if (typeof location !== "undefined" && location.origin.length > 0) { + return url.startsWith(location.origin) + } + return false +} diff --git a/apps/web/src/lib/services/common/api-client-transform.ts b/apps/web/src/lib/services/common/api-client-transform.ts index 0ff4d7b8e..e833f9705 100644 --- a/apps/web/src/lib/services/common/api-client-transform.ts +++ b/apps/web/src/lib/services/common/api-client-transform.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { HttpClient, type HttpClientRequest } from "effect/unstable/http" -import { apiBaseUrl } from "./api-base-url" +import { isMapleApiRequestUrl } from "./api-base-url" import { hasCachedMapleAuthToken, invalidateMapleAuthToken } from "./auth-headers" import { withMapleRetryPolicy } from "./retry-policy" @@ -26,7 +26,7 @@ export const transformMapleApiClient = ( client.pipe( (self) => HttpClient.transform(self, (effect, request) => - request.url.startsWith(apiBaseUrl) + isMapleApiRequestUrl(request.url) ? Effect.annotateSpans(effect, "peer.service", "maple-api") : effect, ), diff --git a/apps/web/src/lib/services/common/electric-sync-url.test.ts b/apps/web/src/lib/services/common/electric-sync-url.test.ts new file mode 100644 index 000000000..e5c0ace1f --- /dev/null +++ b/apps/web/src/lib/services/common/electric-sync-url.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" + +import { resolveElectricSyncBaseUrl, resolveSyncProxyUrl } from "./electric-sync-url" + +describe("resolveElectricSyncBaseUrl", () => { + it("prefers the configured origin over dev and same-origin defaults", () => { + expect( + resolveElectricSyncBaseUrl({ + configured: "https://sync.example.com/", + isDev: true, + origin: "http://127.0.0.1:8080", + }), + ).toBe("https://sync.example.com") + }) + + it("uses the local electric-sync worker in Vite dev", () => { + expect( + resolveElectricSyncBaseUrl({ + configured: undefined, + isDev: true, + origin: "http://127.0.0.1:3471", + }), + ).toBe("http://127.0.0.1:3476") + }) + + it("uses the browser origin when the production SPA is same-origin behind Caddy", () => { + expect( + resolveElectricSyncBaseUrl({ + configured: " ", + isDev: false, + origin: "http://127.0.0.1:8080/", + }), + ).toBe("http://127.0.0.1:8080") + }) +}) + +describe("resolveSyncProxyUrl", () => { + it("is an absolute URL ShapeStream can construct without a base", () => { + const url = resolveSyncProxyUrl("http://127.0.0.1:8080") + expect(() => new URL(url)).not.toThrow() + expect(new URL(url).pathname).toBe("/api/sync/shape") + }) + + it("refuses an empty base instead of emitting a relative path", () => { + expect(() => resolveSyncProxyUrl("")).toThrow(/absolute URL/) + }) +}) diff --git a/apps/web/src/lib/services/common/electric-sync-url.ts b/apps/web/src/lib/services/common/electric-sync-url.ts index 91dd5cd80..6d82ece42 100644 --- a/apps/web/src/lib/services/common/electric-sync-url.ts +++ b/apps/web/src/lib/services/common/electric-sync-url.ts @@ -1,11 +1,45 @@ const configuredElectricSyncUrl = import.meta.env.VITE_ELECTRIC_SYNC_URL?.trim() +const DEV_ELECTRIC_SYNC_ORIGIN = "http://127.0.0.1:3476" +const SYNC_SHAPE_PATH = "/api/sync/shape" + +export const resolveElectricSyncBaseUrl = (input: { + readonly configured: string | undefined + readonly isDev: boolean + readonly origin: string | undefined +}): string => { + const configured = input.configured?.trim() + if (configured && configured.length > 0) return configured.replace(/\/$/, "") + if (input.isDev) return DEV_ELECTRIC_SYNC_ORIGIN + return input.origin?.trim().replace(/\/$/, "") ?? "" +} + +/** + * ShapeStream (`@electric-sql/client`) does `new URL(url)` with no base, so a + * relative `/api/sync/shape` throws. Empty `VITE_ELECTRIC_SYNC_URL` still means + * same-origin behind Caddy — resolve it against the browser origin at call time. + */ +export const resolveSyncProxyUrl = (base: string): string => { + if (base.length === 0) { + throw new Error( + "Electric ShapeStream requires an absolute URL; set VITE_ELECTRIC_SYNC_URL or run in a browser", + ) + } + return `${base}${SYNC_SHAPE_PATH}` +} + +export const getElectricSyncBaseUrl = (): string => + resolveElectricSyncBaseUrl({ + configured: configuredElectricSyncUrl, + isDev: import.meta.env.DEV, + origin: typeof location !== "undefined" ? location.origin : undefined, + }) + +export const getSyncProxyUrl = (): string => resolveSyncProxyUrl(getElectricSyncBaseUrl()) + /** * Origin of the standalone ElectricSQL shape-proxy worker (`apps/electric-sync`). - * Set at build time via `VITE_ELECTRIC_SYNC_URL`; defaults to the local worker's - * dev port (see `apps/electric-sync/package.json` `dev:app`). + * Prefer {@link getElectricSyncBaseUrl} when the value is read after load — + * production same-origin is `location.origin`, which is only defined in a browser. */ -export const electricSyncBaseUrl = - configuredElectricSyncUrl && configuredElectricSyncUrl.length > 0 - ? configuredElectricSyncUrl.replace(/\/$/, "") - : "http://127.0.0.1:3476" +export const electricSyncBaseUrl = getElectricSyncBaseUrl() diff --git a/apps/web/src/lib/services/common/http-client.ts b/apps/web/src/lib/services/common/http-client.ts index 10500ca29..cfb63ad4d 100644 --- a/apps/web/src/lib/services/common/http-client.ts +++ b/apps/web/src/lib/services/common/http-client.ts @@ -1,6 +1,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { Layer } from "effect" -import { apiBaseUrl } from "./api-base-url" +import { isMapleApiRequestUrl } from "./api-base-url" import { getMapleAuthHeaders } from "./auth-headers" import { noteReachable, noteUnreachable, originOf } from "./peer-reachability" @@ -15,7 +15,7 @@ const resolveRequestUrl = (input: RequestInfo | URL): string => { const mapleFetch: typeof globalThis.fetch = async (input, init) => { const headers = new Headers(init?.headers) - if (resolveRequestUrl(input).startsWith(apiBaseUrl)) { + if (isMapleApiRequestUrl(resolveRequestUrl(input))) { const authHeaders = await getMapleAuthHeaders() for (const [name, value] of Object.entries(authHeaders)) { if (!headers.has(name)) { diff --git a/apps/web/wrangler.celld.jsonc b/apps/web/wrangler.celld.jsonc new file mode 100644 index 000000000..c9cf7fc89 --- /dev/null +++ b/apps/web/wrangler.celld.jsonc @@ -0,0 +1,12 @@ +{ + // Asset-only celld config. Slice-1 serves the UI with Vite against the + // celld API (`bun --filter=@maple/web dev:app`). Do not `celld deploy` this + // onto the same local fleet as maple-api — last deploy owns deploy/current.json. + "name": "maple-web", + "compatibility_date": "2025-06-01", + "main": "./src/worker.ts", + "assets": { + "not_found_handling": "single-page-application", + "directory": "./dist" + } +} diff --git a/bun.lock b/bun.lock index 79b45a41a..7dd216745 100644 --- a/bun.lock +++ b/bun.lock @@ -533,6 +533,7 @@ "dependencies": { "@electric-sql/pglite": "^0.5.2", "@maple/domain": "workspace:*", + "@neondatabase/serverless": "^1.1.0", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "postgres": "^3.4.9", @@ -1400,6 +1401,8 @@ "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], diff --git a/deploy/celld-self-host/Caddyfile b/deploy/celld-self-host/Caddyfile new file mode 100644 index 000000000..8adedc099 --- /dev/null +++ b/deploy/celld-self-host/Caddyfile @@ -0,0 +1,34 @@ +# Single public hostname for Maple Cloud self-host. +# Path split: API, Electric sync, OTLP, then the SPA. +{ + admin off +} + +:8080 { + encode gzip + + handle /health { + reverse_proxy api:3472 + } + handle /.well-known/celld/* { + reverse_proxy api:3472 + } + handle /v2/* { + reverse_proxy api:3472 + } + handle /internal/* { + reverse_proxy api:3472 + } + handle /api/sync/* { + reverse_proxy sync:3476 + } + handle /api/* { + reverse_proxy api:3472 + } + handle /v1/* { + reverse_proxy otel:4318 + } + handle { + reverse_proxy web:80 + } +} diff --git a/deploy/celld-self-host/Caddyfile.host b/deploy/celld-self-host/Caddyfile.host new file mode 100644 index 000000000..24362cf97 --- /dev/null +++ b/deploy/celld-self-host/Caddyfile.host @@ -0,0 +1,34 @@ +# Host production check: one origin in front of a running celld + SPA build. +# From repo root: +# caddy run --config deploy/celld-self-host/Caddyfile.host --adapter caddyfile +{ + admin off +} + +:8080 { + encode gzip + + handle /health { + reverse_proxy 127.0.0.1:3472 + } + handle /.well-known/celld/* { + reverse_proxy 127.0.0.1:3472 + } + handle /v2/* { + reverse_proxy 127.0.0.1:3472 + } + handle /internal/* { + reverse_proxy 127.0.0.1:3472 + } + handle /api/sync/* { + reverse_proxy 127.0.0.1:3476 + } + handle /api/* { + reverse_proxy 127.0.0.1:3472 + } + handle { + root * apps/web/dist + try_files {path} /index.html + file_server + } +} diff --git a/deploy/celld-self-host/compose.yml b/deploy/celld-self-host/compose.yml new file mode 100644 index 000000000..e049c3ac0 --- /dev/null +++ b/deploy/celld-self-host/compose.yml @@ -0,0 +1,279 @@ +# Maple Cloud self-host via celld (no Cloudflare, no alchemy). +# +# End user (from this directory): +# cp env.example .env +# docker compose --env-file .env up --build +# Open http://127.0.0.1:8080 +# +# Already have Postgres / ClickHouse / S3? Copy this folder, delete those +# services (and their depends_on), and set MAPLE_PG_URL / S3_ENDPOINT / … in .env. + +name: maple-celld-selfhost + +x-celld: &celld + build: + context: ../.. + dockerfile: deploy/celld-self-host/docker/Dockerfile.celld + env_file: + - path: .env + required: false + environment: + CELLD_VARS_FILE: /run/maple/celld-vars.env + CELLD_WATCH: /var/lib/celld + CELLD_TRUST_FORWARDED_HEADERS: "1" + S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} + AWS_REGION: ${AWS_REGION:-us-east-1} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-maplecelld} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-maplecelld-secret} + AWS_EC2_METADATA_DISABLED: "true" + MAPLE_PG_URL: ${MAPLE_PG_URL:-postgres://maple:maple@postgres:5432/maple} + MAPLE_PG_WS_PROXY: ${MAPLE_PG_WS_PROXY:-ws://pg-ws-proxy:5498} + CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123} + CLICKHOUSE_PROVIDER: ${CLICKHOUSE_PROVIDER:-clickhouse} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-maple} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-maple} + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-default} + ELECTRIC_URL: ${ELECTRIC_URL:-http://electric:3000} + MAPLE_AUTH_MODE: ${MAPLE_AUTH_MODE:-self_hosted} + MAPLE_ROOT_PASSWORD: ${MAPLE_ROOT_PASSWORD:-change-me} + MAPLE_DEFAULT_ORG_ID: ${MAPLE_DEFAULT_ORG_ID:-default} + MAPLE_ENVIRONMENT: ${MAPLE_ENVIRONMENT:-production} + MAPLE_APP_BASE_URL: ${MAPLE_APP_BASE_URL:-http://127.0.0.1:8080} + TINYBIRD_HOST: ${TINYBIRD_HOST:-http://127.0.0.1:7181} + TINYBIRD_TOKEN: ${TINYBIRD_TOKEN:-local-placeholder} + volumes: + - celld-vars:/run/maple + restart: unless-stopped + +services: + vars: + image: alpine:3.21 + env_file: + - path: .env + required: false + environment: + MAPLE_ROOT_PASSWORD: ${MAPLE_ROOT_PASSWORD:-change-me} + MAPLE_INGEST_KEY_ENCRYPTION_KEY: ${MAPLE_INGEST_KEY_ENCRYPTION_KEY:-} + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: ${MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY:-} + TINYBIRD_HOST: ${TINYBIRD_HOST:-http://127.0.0.1:7181} + TINYBIRD_TOKEN: ${TINYBIRD_TOKEN:-local-placeholder} + MAPLE_PG_URL: ${MAPLE_PG_URL:-postgres://maple:maple@postgres:5432/maple} + MAPLE_PG_WS_PROXY: ${MAPLE_PG_WS_PROXY:-ws://pg-ws-proxy:5498} + CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123} + CLICKHOUSE_PROVIDER: clickhouse + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-maple} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-maple} + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-default} + ELECTRIC_URL: ${ELECTRIC_URL:-http://electric:3000} + MAPLE_AUTH_MODE: self_hosted + MAPLE_DEFAULT_ORG_ID: ${MAPLE_DEFAULT_ORG_ID:-default} + MAPLE_APP_BASE_URL: ${MAPLE_APP_BASE_URL:-http://127.0.0.1:8080} + volumes: + - celld-vars:/run/maple + - ./docker/write-vars.sh:/write-vars.sh:ro + command: ["/bin/sh", "/write-vars.sh"] + + minio: + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + command: ["server", "/data", "--console-address", ":9001"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-maplecelld} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-maplecelld-secret} + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 12 + + minio-init: + image: minio/mc:RELEASE.2024-11-17T19-35-25Z + environment: + S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-maplecelld} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-maplecelld-secret} + CELLD_S3_BUCKET: ${CELLD_S3_BUCKET:-maple-celld} + volumes: + - ./docker/minio-init.sh:/minio-init.sh:ro + entrypoint: ["/bin/sh", "/minio-init.sh"] + depends_on: + minio: + condition: service_healthy + restart: "no" + + postgres: + image: postgres:17-alpine + command: ["postgres", "-c", "wal_level=logical"] + environment: + POSTGRES_USER: maple + POSTGRES_PASSWORD: maple + POSTGRES_DB: maple + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U maple -d maple"] + interval: 5s + timeout: 3s + retries: 8 + + clickhouse: + image: clickhouse/clickhouse-server:26.2 + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: maple + CLICKHOUSE_PASSWORD: maple + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + volumes: + - clickhouse-data:/var/lib/clickhouse + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] + interval: 5s + timeout: 3s + retries: 12 + + electric: + image: electricsql/electric:latest + environment: + DATABASE_URL: ${MAPLE_PG_URL:-postgresql://maple:maple@postgres:5432/maple?sslmode=disable} + ELECTRIC_INSECURE: "true" + ELECTRIC_MANUAL_TABLE_PUBLISHING: "true" + depends_on: + postgres: + condition: service_healthy + required: false + + otel: + build: + context: ../.. + dockerfile: otel/otel-collector.Dockerfile + environment: + MAPLE_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-maple} + volumes: + - ../../otel/collector-config.clickhouse.yaml:/etc/otel/config.yaml:ro + ports: + - "4317:4317" + user: "0:0" + + pg-ws-proxy: + build: + context: ../.. + dockerfile: deploy/celld-self-host/docker/Dockerfile.proxy + environment: + MAPLE_PG_URL: ${MAPLE_PG_URL:-postgres://maple:maple@postgres:5432/maple} + MAPLE_PG_WS_ALLOW: ${MAPLE_PG_WS_ALLOW:-postgres} + command: ["bun", "pg-ws-proxy.ts", "--listen", "0.0.0.0:5498", "--target", "postgres:5432"] + restart: unless-stopped + + api: + <<: *celld + environment: + CELLD_APP_DIR: /app/apps/api + CELLD_PORT: "3472" + CELLD_BUCKET: s3://${CELLD_S3_BUCKET:-maple-celld}/api + CELLD_VARS_FILE: /run/maple/celld-vars.env + CELLD_WATCH: /var/lib/celld + S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} + AWS_REGION: ${AWS_REGION:-us-east-1} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-maplecelld} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-maplecelld-secret} + AWS_EC2_METADATA_DISABLED: "true" + MAPLE_PG_URL: ${MAPLE_PG_URL:-postgres://maple:maple@postgres:5432/maple} + MAPLE_PG_WS_PROXY: ${MAPLE_PG_WS_PROXY:-ws://pg-ws-proxy:5498} + volumes: + - celld-vars:/run/maple + - celld-api-watch:/var/lib/celld + depends_on: + vars: + condition: service_started + pg-ws-proxy: + condition: service_started + minio-init: + condition: service_completed_successfully + required: false + + sync: + <<: *celld + environment: + CELLD_APP_DIR: /app/apps/electric-sync + CELLD_PORT: "3476" + CELLD_BUCKET: s3://${CELLD_S3_BUCKET:-maple-celld}/sync + CELLD_VARS_FILE: /run/maple/celld-vars.env + CELLD_WATCH: /var/lib/celld + S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} + AWS_REGION: ${AWS_REGION:-us-east-1} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-maplecelld} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-maplecelld-secret} + AWS_EC2_METADATA_DISABLED: "true" + ELECTRIC_URL: ${ELECTRIC_URL:-http://electric:3000} + MAPLE_ROOT_PASSWORD: ${MAPLE_ROOT_PASSWORD:-change-me} + MAPLE_AUTH_MODE: self_hosted + volumes: + - celld-vars:/run/maple + - celld-sync-watch:/var/lib/celld + depends_on: + vars: + condition: service_started + minio-init: + condition: service_completed_successfully + required: false + + alerting: + <<: *celld + environment: + CELLD_APP_DIR: /app/apps/alerting + CELLD_PORT: "8788" + CELLD_BUCKET: s3://${CELLD_S3_BUCKET:-maple-celld}/alerting + CELLD_VARS_FILE: /run/maple/celld-vars.env + CELLD_WATCH: /var/lib/celld + S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} + AWS_REGION: ${AWS_REGION:-us-east-1} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-maplecelld} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-maplecelld-secret} + AWS_EC2_METADATA_DISABLED: "true" + volumes: + - celld-vars:/run/maple + - celld-alerting-watch:/var/lib/celld + depends_on: + vars: + condition: service_started + pg-ws-proxy: + condition: service_started + minio-init: + condition: service_completed_successfully + required: false + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + args: + VITE_MAPLE_AUTH_MODE: self_hosted + VITE_API_BASE_URL: "" + VITE_ELECTRIC_SYNC_URL: "" + restart: unless-stopped + + caddy: + image: caddy:2.10-alpine + ports: + - "8080:8080" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + depends_on: + - api + - sync + - web + restart: unless-stopped + +volumes: + postgres-data: + clickhouse-data: + minio-data: + celld-vars: + celld-api-watch: + celld-sync-watch: + celld-alerting-watch: diff --git a/deploy/celld-self-host/docker/Dockerfile.celld b/deploy/celld-self-host/docker/Dockerfile.celld new file mode 100644 index 000000000..9ae9986c8 --- /dev/null +++ b/deploy/celld-self-host/docker/Dockerfile.celld @@ -0,0 +1,50 @@ +# celld Worker runtime. Same Maple Worker sources as Cloudflare; no alchemy. +# Build from repo root: docker build -f deploy/celld-self-host/docker/Dockerfile.celld . +FROM oven/bun:1.4.0 AS base +WORKDIR /app + +ARG CELLD_VERSION=v0.4.0 +ARG TARGETARCH=arm64 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gzip \ + && rm -rf /var/lib/apt/lists/* + +RUN set -euo pipefail; \ + case "$TARGETARCH" in \ + amd64) asset=celld-x86_64-unknown-linux-gnu.gz ;; \ + arm64) asset=celld-aarch64-unknown-linux-gnu.gz ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/denoland/celld/releases/download/${CELLD_VERSION}/${asset}" \ + | gzip -d > /usr/local/bin/celld; \ + chmod +x /usr/local/bin/celld; \ + celld --version + +COPY package.json bun.lock bunfig.toml turbo.json ./ +COPY apps ./apps +COPY packages ./packages +COPY lib ./lib +COPY patches ./patches + +ARG ESBUILD_VERSION=0.24.2 +RUN set -euo pipefail; \ + case "$TARGETARCH" in \ + amd64) plat=linux-x64 ;; \ + arm64) plat=linux-arm64 ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://registry.npmjs.org/@esbuild/${plat}/-/${plat}-${ESBUILD_VERSION}.tgz" \ + | tar -xz -C /tmp; \ + install -m 0755 /tmp/package/bin/esbuild /usr/local/bin/esbuild; \ + esbuild --version + +# Workspace install can flake on npm (pglite/alchemy are unused at celld runtime). +RUN bun install || bun install + +COPY deploy/celld-self-host/docker/entrypoint-celld.sh /entrypoint-celld.sh +RUN chmod +x /entrypoint-celld.sh + +ENV CELLD_BIN=/usr/local/bin/celld +EXPOSE 3472 3476 8788 +ENTRYPOINT ["/entrypoint-celld.sh"] diff --git a/deploy/celld-self-host/docker/Dockerfile.proxy b/deploy/celld-self-host/docker/Dockerfile.proxy new file mode 100644 index 000000000..cdb325318 --- /dev/null +++ b/deploy/celld-self-host/docker/Dockerfile.proxy @@ -0,0 +1,10 @@ +# Host-side Postgres WS/HTTP bridge for celld (no TCP in the Worker). +FROM oven/bun:1.4.0-slim +WORKDIR /app +COPY scripts/pg-ws-proxy.ts ./pg-ws-proxy.ts +RUN bun add postgres@3.4.9 \ + && bun -e 'const fs=require("fs"); const p="pg-ws-proxy.ts"; fs.writeFileSync(p, fs.readFileSync(p,"utf8").replace("from \"../packages/db/node_modules/postgres/src/index.js\"","from \"postgres\""))' + +ENV MAPLE_PG_URL=postgres://maple:maple@postgres:5432/maple +EXPOSE 5498 +CMD ["bun", "pg-ws-proxy.ts", "--listen", "0.0.0.0:5498", "--target", "postgres:5432"] diff --git a/deploy/celld-self-host/docker/entrypoint-celld.sh b/deploy/celld-self-host/docker/entrypoint-celld.sh new file mode 100755 index 000000000..de8138219 --- /dev/null +++ b/deploy/celld-self-host/docker/entrypoint-celld.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Production celld: deploy the Worker into the fleet bucket, then serve it. +# Not `celld dev` — that uses PROJECT/.celld/dev and shares nothing with prod. +set -euo pipefail + +APP_DIR="${CELLD_APP_DIR:?CELLD_APP_DIR required}" +PORT="${CELLD_PORT:?CELLD_PORT required}" +CONFIG="${CELLD_WRANGLER:-wrangler.celld.jsonc}" +CELLD_BIN="${CELLD_BIN:-/usr/local/bin/celld}" +BUCKET="${CELLD_BUCKET:?CELLD_BUCKET required (s3://name/prefix)}" +ENDPOINT="${S3_ENDPOINT:?S3_ENDPOINT required}" +REGION="${AWS_REGION:-us-east-1}" + +cd "$APP_DIR" +export PATH="/usr/local/bin:/root/.bun/bin:${PATH}" +export CELLD_ESBUILD="${CELLD_ESBUILD:-$(command -v esbuild)}" +if [[ -z "${CELLD_ESBUILD}" ]]; then + echo "celld-entrypoint: esbuild not on PATH" >&2 + exit 1 +fi +export CELLD_TRUST_FORWARDED_HEADERS="${CELLD_TRUST_FORWARDED_HEADERS:-1}" +export CELLD_WATCH="${CELLD_WATCH:-/var/lib/celld}" +mkdir -p "$CELLD_WATCH" + +echo "celld-entrypoint: deploy $CONFIG → $BUCKET ($ENDPOINT)" +"$CELLD_BIN" deploy "$CONFIG" --bucket "$BUCKET" --endpoint "$ENDPOINT" --region "$REGION" + +echo "celld-entrypoint: listen 0.0.0.0:${PORT}" +exec "$CELLD_BIN" \ + --bucket "$BUCKET" \ + --endpoint "$ENDPOINT" \ + --region "$REGION" \ + --listen "0.0.0.0:${PORT}" \ + --internal-listen "127.0.0.1:0" \ + --trust-forwarded-headers diff --git a/deploy/celld-self-host/docker/minio-init.sh b/deploy/celld-self-host/docker/minio-init.sh new file mode 100755 index 000000000..702843b5e --- /dev/null +++ b/deploy/celld-self-host/docker/minio-init.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +mc alias set local "$S3_ENDPOINT" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" +mc mb -p "local/${CELLD_S3_BUCKET:-maple-celld}" || true +mc anonymous set none "local/${CELLD_S3_BUCKET:-maple-celld}" || true +echo "minio-init: bucket ${CELLD_S3_BUCKET:-maple-celld} ready" diff --git a/deploy/celld-self-host/docker/write-vars.sh b/deploy/celld-self-host/docker/write-vars.sh new file mode 100755 index 000000000..78162d230 --- /dev/null +++ b/deploy/celld-self-host/docker/write-vars.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu +out=/run/maple/celld-vars.env +: > "$out" +for k in \ + MAPLE_ROOT_PASSWORD \ + MAPLE_INGEST_KEY_ENCRYPTION_KEY \ + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY \ + TINYBIRD_HOST \ + TINYBIRD_TOKEN \ + MAPLE_PG_URL \ + MAPLE_PG_WS_PROXY \ + CLICKHOUSE_URL \ + CLICKHOUSE_PROVIDER \ + CLICKHOUSE_USER \ + CLICKHOUSE_PASSWORD \ + CLICKHOUSE_DATABASE \ + ELECTRIC_URL \ + MAPLE_AUTH_MODE \ + MAPLE_DEFAULT_ORG_ID \ + MAPLE_APP_BASE_URL +do + eval "v=\${$k-}" + printf '%s=%s\n' "$k" "$v" >> "$out" +done +printf 'MAPLE_ENVIRONMENT=production\nMAPLE_ALERTING_ALLOW_NONPROD=1\n' >> "$out" +exec tail -f /dev/null diff --git a/deploy/celld-self-host/env.example b/deploy/celld-self-host/env.example new file mode 100644 index 000000000..6fbf8441f --- /dev/null +++ b/deploy/celld-self-host/env.example @@ -0,0 +1,41 @@ +# Copy to .env next to this file, fill the secrets, then: +# docker compose --env-file .env up --build +# Open http://127.0.0.1:8080 password = MAPLE_ROOT_PASSWORD + +MAPLE_ROOT_PASSWORD=change-me +MAPLE_AUTH_MODE=self_hosted +MAPLE_DEFAULT_ORG_ID=default +MAPLE_ENVIRONMENT=production + +# openssl rand -base64 32 +MAPLE_INGEST_KEY_ENCRYPTION_KEY= +# openssl rand -hex 32 +MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY= + +# Required by the API process even when querying ClickHouse. Placeholders are fine. +TINYBIRD_HOST=http://127.0.0.1:7181 +TINYBIRD_TOKEN=local-placeholder + +# Defaults talk to the bundled containers. If you copied compose.yml and deleted +# postgres / minio / clickhouse / electric, point these at your own hosts. +MAPLE_PG_URL=postgres://maple:maple@postgres:5432/maple +MAPLE_PG_WS_PROXY=ws://pg-ws-proxy:5498 +MAPLE_PG_WS_ALLOW=postgres +CLICKHOUSE_URL=http://clickhouse:8123 +CLICKHOUSE_PROVIDER=clickhouse +CLICKHOUSE_USER=maple +CLICKHOUSE_PASSWORD=maple +CLICKHOUSE_DATABASE=default +ELECTRIC_URL=http://electric:3000 + +# Bundled MinIO. Own S3/R2: delete the minio + minio-init services, set these. +CELLD_S3_BUCKET=maple-celld +S3_ENDPOINT=http://minio:9000 +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=maplecelld +AWS_SECRET_ACCESS_KEY=maplecelld-secret +MINIO_ROOT_USER=maplecelld +MINIO_ROOT_PASSWORD=maplecelld-secret + +MAPLE_APP_BASE_URL=http://127.0.0.1:8080 +MAPLE_INGEST_PUBLIC_URL=http://127.0.0.1:8080 diff --git a/deploy/celld-self-host/k8s/alerting.yaml b/deploy/celld-self-host/k8s/alerting.yaml new file mode 100644 index 000000000..5678e9f71 --- /dev/null +++ b/deploy/celld-self-host/k8s/alerting.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-alerting + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-alerting + template: + metadata: + labels: + app: maple-alerting + spec: + containers: + - name: celld + image: maple-celld:dev + env: + - name: CELLD_APP_DIR + value: /app/apps/alerting + - name: CELLD_PORT + value: "8788" + - name: CELLD_BUCKET + value: s3://maple-celld/alerting + - name: CELLD_VARS_FILE + value: /run/maple/celld-vars.env + - name: CELLD_WATCH + value: /var/lib/celld + - name: S3_ENDPOINT + value: http://minio:9000 + - name: AWS_REGION + value: us-east-1 + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_SECRET_ACCESS_KEY + - name: AWS_EC2_METADATA_DISABLED + value: "true" + volumeMounts: + - name: vars + mountPath: /run/maple + volumes: + - name: vars + secret: + secretName: maple-selfhost + items: + - key: CELLD_VARS + path: celld-vars.env diff --git a/deploy/celld-self-host/k8s/api.yaml b/deploy/celld-self-host/k8s/api.yaml new file mode 100644 index 000000000..bac7052f9 --- /dev/null +++ b/deploy/celld-self-host/k8s/api.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-api + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-api + template: + metadata: + labels: + app: maple-api + spec: + containers: + - name: celld + image: maple-celld:dev + args: [] + env: + - name: CELLD_APP_DIR + value: /app/apps/api + - name: CELLD_PORT + value: "3472" + - name: CELLD_BUCKET + value: s3://maple-celld/api + - name: CELLD_VARS_FILE + value: /run/maple/celld-vars.env + - name: CELLD_WATCH + value: /var/lib/celld + - name: S3_ENDPOINT + valueFrom: + configMapKeyRef: + name: maple-selfhost + key: S3_ENDPOINT + - name: AWS_REGION + value: us-east-1 + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_SECRET_ACCESS_KEY + - name: AWS_EC2_METADATA_DISABLED + value: "true" + - name: CELLD_TRUST_FORWARDED_HEADERS + value: "1" + volumeMounts: + - name: vars + mountPath: /run/maple + - name: watch + mountPath: /var/lib/celld + ports: + - containerPort: 3472 + volumes: + - name: vars + secret: + secretName: maple-selfhost + items: + - key: CELLD_VARS + path: celld-vars.env + - name: watch + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: maple +spec: + selector: + app: maple-api + ports: + - port: 3472 + targetPort: 3472 diff --git a/deploy/celld-self-host/k8s/caddy.yaml b/deploy/celld-self-host/k8s/caddy.yaml new file mode 100644 index 000000000..64292cfbf --- /dev/null +++ b/deploy/celld-self-host/k8s/caddy.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: maple-caddy + namespace: maple +data: + Caddyfile: | + :80 { + encode gzip + handle /health { reverse_proxy api:3472 } + handle /.well-known/celld/* { reverse_proxy api:3472 } + handle /v2/* { reverse_proxy api:3472 } + handle /internal/* { reverse_proxy api:3472 } + handle /api/sync/* { reverse_proxy sync:3476 } + handle /api/* { reverse_proxy api:3472 } + handle /v1/* { reverse_proxy otel:4318 } + handle { reverse_proxy web:80 } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-caddy + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-caddy + template: + metadata: + labels: + app: maple-caddy + spec: + containers: + - name: caddy + image: caddy:2.10-alpine + ports: + - containerPort: 80 + volumeMounts: + - name: caddyfile + mountPath: /etc/caddy/Caddyfile + subPath: Caddyfile + volumes: + - name: caddyfile + configMap: + name: maple-caddy +--- +apiVersion: v1 +kind: Service +metadata: + name: maple + namespace: maple +spec: + selector: + app: maple-caddy + ports: + - name: http + port: 80 + targetPort: 80 + - name: otlp-grpc + port: 4317 + targetPort: 4317 diff --git a/deploy/celld-self-host/k8s/configmap.yaml b/deploy/celld-self-host/k8s/configmap.yaml new file mode 100644 index 000000000..01b74cbd5 --- /dev/null +++ b/deploy/celld-self-host/k8s/configmap.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: maple-selfhost + namespace: maple +data: + MAPLE_AUTH_MODE: self_hosted + MAPLE_DEFAULT_ORG_ID: default + MAPLE_ENVIRONMENT: production + MAPLE_PG_URL: postgres://maple:maple@postgres:5432/maple + MAPLE_PG_WS_PROXY: ws://pg-ws-proxy:5498 + MAPLE_PG_WS_ALLOW: postgres + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_PROVIDER: clickhouse + CLICKHOUSE_USER: maple + CLICKHOUSE_DATABASE: default + ELECTRIC_URL: http://electric:3000 + MAPLE_APP_BASE_URL: http://maple.example.internal + TINYBIRD_HOST: http://127.0.0.1:7181 + TINYBIRD_TOKEN: local-placeholder + S3_ENDPOINT: http://minio:9000 + AWS_REGION: us-east-1 + CELLD_S3_BUCKET: maple-celld +--- +# Fill from a sealed-secret / external-secret in real deploys. +apiVersion: v1 +kind: Secret +metadata: + name: maple-selfhost + namespace: maple +type: Opaque +stringData: + MAPLE_ROOT_PASSWORD: change-me + MAPLE_INGEST_KEY_ENCRYPTION_KEY: replace-me + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: replace-me + CLICKHOUSE_PASSWORD: maple + CELLD_VARS: | + MAPLE_AUTH_MODE=self_hosted + MAPLE_DEFAULT_ORG_ID=default + MAPLE_ENVIRONMENT=production + MAPLE_ROOT_PASSWORD=change-me + MAPLE_INGEST_KEY_ENCRYPTION_KEY=replace-me + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY=replace-me + TINYBIRD_HOST=http://127.0.0.1:7181 + TINYBIRD_TOKEN=local-placeholder + MAPLE_PG_URL=postgres://maple:maple@postgres:5432/maple + MAPLE_PG_WS_PROXY=ws://pg-ws-proxy:5498 + CLICKHOUSE_URL=http://clickhouse:8123 + CLICKHOUSE_PROVIDER=clickhouse + CLICKHOUSE_USER=maple + CLICKHOUSE_PASSWORD=maple + CLICKHOUSE_DATABASE=default + ELECTRIC_URL=http://electric:3000 + MAPLE_APP_BASE_URL=http://maple.example.internal + MAPLE_ALERTING_ALLOW_NONPROD=1 diff --git a/deploy/celld-self-host/k8s/kustomization.yaml b/deploy/celld-self-host/k8s/kustomization.yaml new file mode 100644 index 000000000..2f91319ba --- /dev/null +++ b/deploy/celld-self-host/k8s/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: maple +resources: + - namespace.yaml + - configmap.yaml + - minio.yaml + - proxy.yaml + - api.yaml + - sync.yaml + - alerting.yaml + - web.yaml + - caddy.yaml +# Own S3: delete minio.yaml and set CELLD_BUCKET / S3_ENDPOINT / AWS_* on the +# celld Deployments. Own Postgres/CH/Electric: set URLs on maple-selfhost ConfigMap. diff --git a/deploy/celld-self-host/k8s/minio.yaml b/deploy/celld-self-host/k8s/minio.yaml new file mode 100644 index 000000000..5050764e7 --- /dev/null +++ b/deploy/celld-self-host/k8s/minio.yaml @@ -0,0 +1,102 @@ +apiVersion: v1 +kind: Secret +metadata: + name: maple-minio + namespace: maple +type: Opaque +stringData: + MINIO_ROOT_USER: maplecelld + MINIO_ROOT_PASSWORD: maplecelld-secret + AWS_ACCESS_KEY_ID: maplecelld + AWS_SECRET_ACCESS_KEY: maplecelld-secret +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-minio + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-minio + template: + metadata: + labels: + app: maple-minio + spec: + containers: + - name: minio + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + args: ["server", "/data", "--console-address", ":9001"] + envFrom: + - secretRef: + name: maple-minio + ports: + - containerPort: 9000 + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /minio/health/live + port: 9000 + initialDelaySeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: maple-minio +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: maple-minio + namespace: maple +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: maple +spec: + selector: + app: maple-minio + ports: + - port: 9000 + targetPort: 9000 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: maple-minio-init + namespace: maple +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: minio/mc:RELEASE.2024-11-17T19-35-25Z + env: + - name: S3_ENDPOINT + value: http://minio:9000 + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: maple-minio + key: MINIO_ROOT_USER + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: maple-minio + key: MINIO_ROOT_PASSWORD + - name: CELLD_S3_BUCKET + value: maple-celld + command: ["/bin/sh", "-c"] + args: + - mc alias set local "$S3_ENDPOINT" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" && mc mb -p "local/$CELLD_S3_BUCKET" diff --git a/deploy/celld-self-host/k8s/namespace.yaml b/deploy/celld-self-host/k8s/namespace.yaml new file mode 100644 index 000000000..80fd8e8e5 --- /dev/null +++ b/deploy/celld-self-host/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: maple diff --git a/deploy/celld-self-host/k8s/proxy.yaml b/deploy/celld-self-host/k8s/proxy.yaml new file mode 100644 index 000000000..02c180a62 --- /dev/null +++ b/deploy/celld-self-host/k8s/proxy.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-pg-ws-proxy + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-pg-ws-proxy + template: + metadata: + labels: + app: maple-pg-ws-proxy + spec: + containers: + - name: proxy + image: maple-pg-ws-proxy:dev + envFrom: + - configMapRef: + name: maple-selfhost + ports: + - containerPort: 5498 +--- +apiVersion: v1 +kind: Service +metadata: + name: pg-ws-proxy + namespace: maple +spec: + selector: + app: maple-pg-ws-proxy + ports: + - port: 5498 + targetPort: 5498 diff --git a/deploy/celld-self-host/k8s/sync.yaml b/deploy/celld-self-host/k8s/sync.yaml new file mode 100644 index 000000000..88990e95a --- /dev/null +++ b/deploy/celld-self-host/k8s/sync.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-sync + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-sync + template: + metadata: + labels: + app: maple-sync + spec: + containers: + - name: celld + image: maple-celld:dev + env: + - name: CELLD_APP_DIR + value: /app/apps/electric-sync + - name: CELLD_PORT + value: "3476" + - name: CELLD_BUCKET + value: s3://maple-celld/sync + - name: CELLD_VARS_FILE + value: /run/maple/celld-vars.env + - name: CELLD_WATCH + value: /var/lib/celld + - name: S3_ENDPOINT + value: http://minio:9000 + - name: AWS_REGION + value: us-east-1 + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: maple-minio + key: AWS_SECRET_ACCESS_KEY + - name: AWS_EC2_METADATA_DISABLED + value: "true" + volumeMounts: + - name: vars + mountPath: /run/maple + ports: + - containerPort: 3476 + volumes: + - name: vars + secret: + secretName: maple-selfhost + items: + - key: CELLD_VARS + path: celld-vars.env +--- +apiVersion: v1 +kind: Service +metadata: + name: sync + namespace: maple +spec: + selector: + app: maple-sync + ports: + - port: 3476 + targetPort: 3476 diff --git a/deploy/celld-self-host/k8s/web.yaml b/deploy/celld-self-host/k8s/web.yaml new file mode 100644 index 000000000..1789377e5 --- /dev/null +++ b/deploy/celld-self-host/k8s/web.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-web + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-web + template: + metadata: + labels: + app: maple-web + spec: + containers: + - name: nginx + image: maple-web:dev + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: web + namespace: maple +spec: + selector: + app: maple-web + ports: + - port: 80 + targetPort: 80 diff --git a/docs/celld-self-host.md b/docs/celld-self-host.md new file mode 100644 index 000000000..e1646ca66 --- /dev/null +++ b/docs/celld-self-host.md @@ -0,0 +1,300 @@ +# Maple Cloud on celld (self-host) + +> **Proof of concept.** This is an experiment in running Maple Cloud on +> [celld](https://celld.dev) so a VPS self-host does not need a Cloudflare +> account. It is not the official production deploy. Hosted Maple still uses +> Alchemy / Workers; telemetry-only installs should keep using local-mode +> `maple start`. + +Run Maple **Cloud** (the Workers API + web UI, not local-mode `maple start`) on +your own machine or VPS **without Cloudflare Workers, wrangler, Miniflare, or a +Cloudflare account**. + +This is slice-2: **web UI + API + electric-sync + alerting** talking to +**docker Postgres + ClickHouse**, with self-hosted password auth and interactive +Postgres transactions. It is not a rewrite of the hosted Cloud stack. + +## Why celld + +[celld](https://celld.dev) is a self-hosted runtime for Cloudflare Workers and +Durable Objects. Maple's API is a Worker. celld v0.4.0 runs that Worker from a +stripped Wrangler config (`apps/api/wrangler.celld.jsonc`) so we keep one +codepath instead of a second Node server. + +Install docs: [celld.dev](https://celld.dev) · source: +[github.com/denoland/celld](https://github.com/denoland/celld). + +## One-command local start + +From the repo root, with `.env.local` already filled (see +[Required env](#required-env)): + +```bash +bash scripts/celld-dev.sh +``` + +That script: + +1. Ensures docker Postgres (`:5499`), Electric (`:3473`), and ClickHouse (`:8123`) + are up (`bun db:up`, `bun ch:up`), applies Postgres migrations, then applies + the ClickHouse schema (`packages/clickhouse-cli`). It does **not** stop + unrelated stacks. CH apply is idempotent; it fails the start only on a real + error, not when the schema is already current. +2. Starts `scripts/pg-ws-proxy.ts` on `:5498` (Neon-compatible WebSocket ↔ TCP + tunnel onto Postgres, plus `POST /sql` for tests). +3. Installs celld **v0.4.0** into `.tools/celld` if needed (macOS arm64 gzip from + GitHub releases). +4. Runs **three** `celld dev` processes (one public Worker each; never the same + fleet / `deploy/current.json`): + - `apps/api` on **`:3472`** + - `apps/electric-sync` on **`:3476`** (web `VITE_ELECTRIC_SYNC_URL`) + - `apps/alerting` on **`:8788`** (scheduled-only; `fetch` is 404) +5. Starts Vite `apps/web` on `:3471` against that API (`START_WEB=0` to skip). + Reuses an already-running Vite rather than fighting it. + +Health: + +```bash +curl -sS http://127.0.0.1:3472/health +# OK +curl -sS http://127.0.0.1:3472/.well-known/celld/health +``` + +Sign in at `http://127.0.0.1:3471` with `MAPLE_AUTH_MODE=self_hosted` and +`MAPLE_ROOT_PASSWORD` from `.env.local` (docs examples use `change-me`). Login +itself is HMAC JWT and does **not** hit Postgres. A control-plane route such as +`GET /v2/dashboards` does. Interactive transactions (alert-rule create, API-key +roll, share rotate) go through `@neondatabase/serverless` over the WS proxy. + +```bash +TOKEN=$(curl -sS http://127.0.0.1:3472/api/auth/login \ + -H 'content-type: application/json' \ + -d '{"password":"change-me"}' | jq -r .token) +curl -sS http://127.0.0.1:3472/v2/dashboards \ + -H "authorization: Bearer $TOKEN" +curl -sS http://127.0.0.1:3472/v2/dashboards \ + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"name":"celld-test"}' +curl -sS http://127.0.0.1:3472/v2/alerts/rules \ + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"name":"celld-test","severity":"critical","signal_type":"error_rate","comparator":"gt","threshold":0.05,"window_minutes":5,"destination_ids":[]}' +curl -sS -o /dev/null -w '%{http_code}\n' \ + 'http://127.0.0.1:3476/api/sync/shape?shape=dashboards' +``` + +Local celld state lives in each app's `.celld/dev` (`apps/api`, +`apps/electric-sync`, `apps/alerting`). Add `.celld/` to gitignore (already done +at the repo root). + +## Ports + +| Port | Process | Notes | +| ---- | ----------------------------- | ---------------------------------------------------------------------------- | +| 3472 | celld (`maple-api`) | Default; wrangler used the same port | +| 3476 | celld (`maple-electric-sync`) | Own `.celld/dev`. Web `VITE_ELECTRIC_SYNC_URL` | +| 8788 | celld (`maple-alerting`) | Own `.celld/dev`. `fetch` is 404 "scheduled only" | +| 3471 | Vite `apps/web` | Slice-1 UI. celld assets are optional later | +| 5499 | docker Postgres | Logical URL host for `MAPLE_PG_URL` | +| 5498 | `pg-ws-proxy` | Neon `/v1` WS pipe + `POST /sql` (tests only). celld cannot TCP-dial `:5499` | +| 8123 | docker ClickHouse HTTP | Schema applied by `clickhouse-cli` during `dev:celld` | +| 3473 | docker Electric | Upstream for `apps/electric-sync` (`ELECTRIC_URL`) | +| 9876 | celld default | Unused here; we pass `--port` per process | + +celld itself answers `GET /.well-known/celld/health` (moved in v0.4). + +Shape `503` with `missing from the publication "electric_publication_default"` +means drizzle recorded `0009_electric_publication` while the `WHEN OTHERS` +guard swallowed `CREATE PUBLICATION` (see [electric-sync.md](./electric-sync.md) +Troubleshooting). `dev:celld` now heals membership after migrate. To repair a +live volume without restarting: add the six shape tables to that publication +and restart `maple-electric-1`. + +The browser HTTP/1.1 “~6 concurrent connections” warning is expected on local +HTTP (no HTTP/2). It is not the 503. + +## Required env + +`.env.local` (never committed). The start script copies a whitelist into +`.tools/celld-vars.env` and points celld at it with `CELLD_VARS_FILE`. + +Must be set (Env dies without them; `/health` still 200, everything else 504): + +- `TINYBIRD_HOST` / `TINYBIRD_TOKEN` — placeholders are fine in slice-1 +- `MAPLE_INGEST_KEY_ENCRYPTION_KEY` — base64 of 32 bytes +- `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY` +- `MAPLE_ROOT_PASSWORD` +- `MAPLE_AUTH_MODE=self_hosted` +- `MAPLE_DEFAULT_ORG_ID` (default `default`) + +The script always overlays: + +- `MAPLE_PG_URL=postgres://maple:maple@127.0.0.1:5499/maple` +- `MAPLE_PG_WS_PROXY=ws://127.0.0.1:5498` +- `CLICKHOUSE_URL=http://127.0.0.1:8123` +- `CLICKHOUSE_PROVIDER=clickhouse` +- `ELECTRIC_URL=http://127.0.0.1:3473` +- `MAPLE_ALERTING_ALLOW_NONPROD=1` + +**Do not overload `MAPLE_DB_URL`.** That variable is the PGlite data directory +(`packages/db/src/config.ts`), not a Postgres URL. + +`MAPLE_DB` remains a Hyperdrive **object** on Cloudflare. A string `MAPLE_DB` +is still Unavailable on purpose. celld uses `MAPLE_PG_URL` instead. + +## What is stubbed / omitted in slice-2 + +celld v0.4.0 accepted wrangler keys: `$schema`, `name`, `main`, `no_bundle`, +`compatibility_date`, `compatibility_flags`, `durable_objects`, `migrations`, +`assets`, `services`, `triggers`, `vars`, `d1_databases`, `kv_namespaces`, +`queues`, `workflows`, `r2_buckets`. + +Forbidden (they stop deploy): `hyperdrive`, `ai`, `ratelimits`, `send_email`, +`routes`, `dev`, `workers_dev`. + +| Binding / feature | Slice-2 | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Hyperdrive `MAPLE_DB` | Absent. Fallback: `MAPLE_PG_URL` + neon-serverless over WS proxy | +| TCP `connect()` / `cloudflare:sockets` / `node:net` | Inert stub. Postgres goes through WebSockets | +| EMAIL (`send_email`) | Missing → EmailService skips / errors as today | +| `API_V2_RATE_LIMITER` | Missing → fail-open | +| Workers AI | Missing. LLM stays on OpenRouter HTTP when configured | +| Queues (VCS sync, PlanetScale webhooks) | Omitted. celld refuses a queue consumer on a Worker that also exports `fetch` | +| Workflows / ChatSession DO / MCP KV | Declared with accepted keys; Partial in celld | +| Cron | Declared; Partial | +| Landing, AI triage, email, PlanetScale, VCS sync | Out of slice-2 | +| `apps/electric-sync` | Second `celld dev` on `:3476`. DB-free HTTP proxy to docker Electric | +| Web on celld | Config exists (`apps/web/wrangler.celld.jsonc`); local still uses Vite. Do not `celld deploy` web onto the same local fleet as the API — last deploy owns `deploy/current.json` | +| Alerting worker | Third `celld dev` on `:8788`. Same `MAPLE_PG_URL` + WS proxy as api. `fetch` 404; crons Partial in celld | + +The existing Hyperdrive path is unchanged: wrangler / alchemy still bind +`MAPLE_DB` as an object, and that branch wins over `MAPLE_PG_URL`. + +## Postgres without TCP + +celld cannot dial `127.0.0.1:5499`. Host-side `scripts/pg-ws-proxy.ts` (Bun) +listens on `127.0.0.1:5498` and copies binary WebSocket frames to Postgres TCP. + +Inside the Worker, `createMaplePgSocket` sees `MAPLE_PG_WS_PROXY` and uses +`@neondatabase/serverless` + `drizzle-orm/neon-serverless` through a Neon +`/v1?address=host:port` WebSocket pipe (`neonConfig.webSocketConstructor = +WebSocket`, `useSecureWebSocket = false`, `pipelineConnect = false`). The Pool +is request-scoped (created in the connection scope, `end()` on close) because +celld closes outbound WebSockets after the response. workerd postgres.js can +copy bytes through a raw WebSocket tunnel, but SCRAM against real Postgres +fails (`malformed SCRAM message`) — do not retry that as the primary path. +Unset `MAPLE_PG_WS_PROXY` keeps today's TCP postgres.js path for wrangler and +Hyperdrive. + +`POST /sql` on the same proxy is tests/fallback only (drizzle-orm/pg-proxy cannot +`db.transaction()`). The unpathed WebSocket remains a raw byte tunnel for tests +(`packages/db/src/pg-ws-socket.ts`). + +Official `ghcr.io/neondatabase/wsproxy` is an alternative on a **different** +loopback port (`MAPLE_PG_WS_PROXY` pointed there). On macOS/OrbStack, +`--network host` may not work; prefer `-p` + `host.docker.internal:5499` in +`ALLOW_ADDR_REGEX`. + +SSL through the tunnel is not in this slice (local docker is unencrypted). + +## Production packaging + +Dev (`bash scripts/celld-dev.sh`) is the compatibility experiment. Production files live in +[`deploy/celld-self-host/`](../deploy/celld-self-host/) (Caddy, Compose, Kustomize). +Alchemy is **not** used. Workers use `apps/*/wrangler.celld.jsonc`. + +**Prod data is never the dev data plane.** `bash scripts/celld-dev.sh` uses compose +project `maple` volumes (`maple_postgres-data`, `maple_clickhouse-data`) and +`apps/*/.celld/dev`. Production Compose uses project `maple-celld-selfhost` +(own Postgres/ClickHouse/MinIO volumes) and `celld deploy` into S3 prefixes, +not `celld dev`. Host Caddy in front of `:3472` is only an ingress check; it +still reads the **dev** stores until you run the Compose/K8s recipes. + +**S3 is on by default.** The stock compose/k8s start MinIO +(`s3://maple-celld/{api,sync,alerting}`). Already have S3 or Postgres? **Copy** +`deploy/celld-self-host/`, delete those services (and `depends_on` / `k8s/minio.yaml`), +and put your URLs in `.env`. + +Verify in this order: **host Caddy (ingress)** → **Compose (real prod data)** → **K8s**. + +### Host (single origin in front of a running celld) + +With api `:3472`, electric-sync `:3476`, and a production SPA build: + +```bash +VITE_MAPLE_AUTH_MODE=self_hosted VITE_API_BASE_URL= VITE_ELECTRIC_SYNC_URL= \ + bun run --cwd apps/web build +caddy run --config deploy/celld-self-host/Caddyfile.host --adapter caddyfile +``` + +Open http://127.0.0.1:8080 — password is `MAPLE_ROOT_PASSWORD`. Caddy proxies +`/v2` `/api` `/health` to api, `/api/sync` to electric-sync, and serves `apps/web/dist`. + +Same-origin only works after a production build with empty `VITE_*` URLs. The SPA +resolves the Electric shape proxy to `location.origin/api/sync/shape` at runtime +(`ShapeStream` cannot take a relative URL). Vite on `:3471` still bakes +`localhost:3472` into the bundle. + +### Compose (this is the product) + +```bash +cd deploy/celld-self-host +cp env.example .env +# set MAPLE_ROOT_PASSWORD and the two MAPLE_INGEST_KEY_* secrets +docker compose --env-file .env up --build +``` + +Open http://127.0.0.1:8080 (password = `MAPLE_ROOT_PASSWORD`). OTLP HTTP is `/v1/traces` +on that origin; OTLP gRPC is `:4317`. + +Already have Postgres or S3? Copy this folder, delete the `postgres` / `minio` +(and `minio-init`) services plus their `depends_on`, then in `.env`: + +``` +MAPLE_PG_URL=postgres://user:pass@db.internal:5432/maple +MAPLE_PG_WS_ALLOW=db.internal +S3_ENDPOINT=https://s3.amazonaws.com +AWS_ACCESS_KEY_ID=… +AWS_SECRET_ACCESS_KEY=… +CELLD_S3_BUCKET=your-bucket +``` + +First boot still needs Drizzle migrate + ClickHouse schema + Electric publication +against those URLs (same heal as `dev:celld`). + +### Kubernetes + +```bash +kubectl apply -k deploy/celld-self-host/k8s +``` + +Point ConfigMap/Secret `MAPLE_PG_URL` / `CLICKHOUSE_URL` / `ELECTRIC_URL` at existing +cluster services. Images: `maple-celld:dev`, `maple-web:dev`, `maple-pg-ws-proxy:dev`. + +### Layout + +``` +deploy/celld-self-host/ + Caddyfile # docker service names + Caddyfile.host # 127.0.0.1 backends + apps/web/dist + compose.yml + env.example + docker/ + k8s/ +``` + +## VPS notes + +A VPS bring-up is the data plane plus one celld process per public Worker: + +1. **docker data plane** — Postgres (logical replication for Electric), + ClickHouse HTTP `:8123` (apply schema with `clickhouse-cli`). +2. **`pg-ws-proxy`** on loopback, targeting docker Postgres. Do not publish + `:5498` or `:5499` past localhost. +3. **three celld processes** — `maple-api`, `maple-electric-sync`, `maple-alerting`. + One `celld` process = one public Worker; do not `celld deploy` them onto the + same fleet. Locally that is three `celld dev` invocations from three app + directories (separate `.celld/dev`). Terminate TLS on the ingress proxy; + celld does not. + +Put `CELLD_VARS_FILE` next to the node (or `CELLD_VAR_*`); process env wins over +wrangler `vars`. Never commit secrets. diff --git a/packages/db/package.json b/packages/db/package.json index aa0471575..5f8181bda 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -26,6 +26,7 @@ "dependencies": { "@electric-sql/pglite": "^0.5.2", "@maple/domain": "workspace:*", + "@neondatabase/serverless": "^1.1.0", "drizzle-orm": "^0.45.1", "effect": "catalog:effect", "postgres": "^3.4.9" diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts new file mode 100644 index 000000000..1ab88eb85 --- /dev/null +++ b/packages/db/src/client.test.ts @@ -0,0 +1,65 @@ +import { sql } from "drizzle-orm" +import { describe, expect, it } from "vitest" +import { createMaplePgSocket } from "./client" + +const defaultPgUrl = process.env.MAPLE_PG_URL ?? "postgres://maple:maple@127.0.0.1:5499/maple" +const defaultWsProxyUrl = process.env.MAPLE_PG_WS_PROXY ?? "ws://127.0.0.1:5498" + +const sqlUrlFor = (wsProxyUrl: string): string => { + if (wsProxyUrl.startsWith("wss://")) return `https://${wsProxyUrl.slice("wss://".length)}/sql` + if (wsProxyUrl.startsWith("ws://")) return `http://${wsProxyUrl.slice("ws://".length)}/sql` + return `${wsProxyUrl.replace(/\/$/, "")}/sql` +} + +const isWsProxyUp = async (wsProxyUrl: string): Promise => { + try { + const response = await fetch(sqlUrlFor(wsProxyUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sql: "select 1", params: [], method: "execute" }), + signal: AbortSignal.timeout(1_000), + }) + return response.ok + } catch { + return false + } +} + +describe("createMaplePgSocket", () => { + it("does not install a proxy client when wsProxyUrl is absent", async () => { + const handle = createMaplePgSocket(defaultPgUrl) + expect(handle.wrapClient).toBeUndefined() + expect(handle.sql).toBeTruthy() + await handle.end() + }) + + it("installs a neon-serverless wrapClient when wsProxyUrl is set", async () => { + const handle = createMaplePgSocket(defaultPgUrl, { + wsProxyUrl: defaultWsProxyUrl, + }) + expect(typeof handle.wrapClient).toBe("function") + const db = handle.wrapClient?.() + expect(db).toBeDefined() + expect(typeof db?.transaction).toBe("function") + await handle.end() + }) + + it("runs a drizzle transaction over the neon websocket proxy when it is up", async (ctx) => { + if (!(await isWsProxyUp(defaultWsProxyUrl))) { + ctx.skip() + return + } + const handle = createMaplePgSocket(defaultPgUrl, { wsProxyUrl: defaultWsProxyUrl }) + try { + const db = handle.wrapClient?.() + expect(db).toBeDefined() + const result = await db!.transaction(async (tx) => { + await tx.execute(sql`select 1::int as x`) + return 41 + 1 + }) + expect(result).toBe(42) + } finally { + await handle.end() + } + }) +}) diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 7ad00a300..9dcd551ed 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,4 +1,6 @@ import type { PGlite } from "@electric-sql/pglite" +import { neonConfig, Pool } from "@neondatabase/serverless" +import { drizzle as drizzleNeon } from "drizzle-orm/neon-serverless" import { drizzle as drizzlePglite } from "drizzle-orm/pglite" import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js" import postgres from "postgres" @@ -25,6 +27,13 @@ export interface MaplePgSocketHandle { readonly sql: MaplePgSocket /** Closes the underlying postgres.js connection pool. */ readonly end: () => Promise + /** + * Bind drizzle for one logical call. Defaults to `wrapMaplePgClient`. + * celld uses drizzle-orm/neon-serverless over the host WS proxy because + * workerd postgres.js SCRAM against real Postgres fails (`malformed SCRAM + * message`) and drizzle-orm/pg-proxy cannot `db.transaction()`. + */ + readonly wrapClient?: (options?: Pick) => MaplePgClient } export interface MaplePgSocketOptions { @@ -42,6 +51,13 @@ export interface MaplePgSocketOptions { * `connect_timeout` calls `socket.destroy()` and frees the slot. */ readonly connectTimeoutSeconds?: number + /** + * Host-side `scripts/pg-ws-proxy.ts`. On celld the worker uses + * `@neondatabase/serverless` through the proxy's Neon `/v1` WebSocket pipe. + * POST `/sql` on the same process is tests/fallback only. Unset keeps the + * current TCP postgres.js path for wrangler / Hyperdrive. + */ + readonly wsProxyUrl?: string } export interface MaplePgClientOptions extends MaplePgSocketOptions { @@ -81,10 +97,54 @@ const toDrizzleLogger = (onQuery: ((query: string) => void) | undefined) => * across reuse of one long-lived connection, which a request-lived client by * definition does not have. Do not flip it back without measuring. */ +/** + * Point `@neondatabase/serverless` at the host `pg-ws-proxy` Neon `/v1` pipe. + * `neonConfig` is process-global; this is idempotent for a single proxy URL. + */ +const configureNeonWsProxy = (wsProxyUrl: string): void => { + const proxy = new URL(wsProxyUrl) + neonConfig.webSocketConstructor = WebSocket + neonConfig.useSecureWebSocket = proxy.protocol === "wss:" + neonConfig.pipelineConnect = false + neonConfig.pipelineTLS = false + neonConfig.forceDisablePgSSL = true + neonConfig.wsProxy = (host, port) => `${proxy.host}/v1?address=${host}:${port}` +} + +const createNeonHandle = ( + connectionString: string, + wsProxyUrl: string, + options: MaplePgSocketOptions, +): MaplePgSocketHandle => { + configureNeonWsProxy(wsProxyUrl) + const connectTimeoutSeconds = options.connectTimeoutSeconds + const pool = new Pool({ + connectionString, + max: options.maxConnections ?? 5, + ...(!(connectTimeoutSeconds === undefined) + ? { connectionTimeoutMillis: connectTimeoutSeconds * 1000 } + : undefined), + }) + const wrapClient = (wrapOptions?: Pick): MaplePgClient => + drizzleNeon(pool, { + schema, + logger: toDrizzleLogger(wrapOptions?.onQuery), + }) as unknown as MaplePgClient + return { + // Unused on the neon path; wrapClient is the drizzle factory. + sql: null as unknown as MaplePgSocket, + end: () => pool.end(), + wrapClient, + } +} + export const createMaplePgSocket = ( connectionString: string, options?: MaplePgSocketOptions, ): MaplePgSocketHandle => { + if (options?.wsProxyUrl !== undefined) { + return createNeonHandle(connectionString, options.wsProxyUrl, options) + } const connectTimeoutSeconds = options?.connectTimeoutSeconds const sql = postgres(connectionString, { max: options?.maxConnections ?? 5, diff --git a/packages/db/src/pg-ws-socket.ts b/packages/db/src/pg-ws-socket.ts new file mode 100644 index 000000000..20f74d3f0 --- /dev/null +++ b/packages/db/src/pg-ws-socket.ts @@ -0,0 +1,249 @@ +/** + * A Node-like duplex over the WebSocket API, for postgres.js `socket`. + * + * celld's workerd build of postgres.js honors `options.socket` and skips + * `cloudflare:sockets` (an inert stub). The factory must return an already-open + * duplex: postgres.js then writes the startup message immediately. + * + * This module is the Workers-side half of `scripts/pg-ws-proxy.ts`. It uses the + * WebSocket API only — not `node:net`. + */ +import { Buffer } from "node:buffer" + +type SocketListener = (...args: unknown[]) => void + +const toBytes = (data: unknown): Uint8Array => { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) + } + if (typeof data === "string") { + // Celld may deliver binary frames as JS strings (one code unit per byte). + const out = new Uint8Array(data.length) + for (let i = 0; i < data.length; i += 1) out[i] = data.charCodeAt(i) & 0xff + return out + } + return new Uint8Array() +} + +const toSendable = (chunk: Uint8Array | string): ArrayBuffer | string => { + if (typeof chunk === "string") return chunk + const copy = new Uint8Array(chunk.byteLength) + copy.set(chunk) + return copy.buffer +} + +export class MaplePgWsDuplex { + readyState = "open" + readonly #ws: WebSocket + readonly #listeners = new Map>() + + constructor(ws: WebSocket) { + this.#ws = ws + const onMessage = (event: MessageEvent | { readonly data: unknown }) => { + const payload = event.data + if (typeof Blob !== "undefined" && payload instanceof Blob) { + void payload.arrayBuffer().then((buffer) => { + const buf = Buffer.from(buffer) + this.#emit("data", buf) + }) + return + } + const buf = Buffer.from(toBytes(payload)) + this.#emit("data", buf) + } + const onError = () => this.#emit("error", new Error("postgres websocket error")) + const onClose = () => { + this.readyState = "closed" + this.#emit("close") + } + ws.addEventListener("message", onMessage) + ws.addEventListener("error", onError) + ws.addEventListener("close", onClose) + ws.onmessage = onMessage + ws.onerror = onError + ws.onclose = onClose + } + + on(event: string, listener: SocketListener): this { + let bucket = this.#listeners.get(event) + if (bucket === undefined) { + bucket = new Set() + this.#listeners.set(event, bucket) + } + bucket.add(listener) + return this + } + + once(event: string, listener: SocketListener): this { + const wrapped: SocketListener = (...args) => { + this.removeListener(event, wrapped) + listener(...args) + } + return this.on(event, wrapped) + } + + removeListener(event: string, listener: SocketListener): this { + this.#listeners.get(event)?.delete(listener) + return this + } + + removeAllListeners(event?: string): this { + if (event === undefined) this.#listeners.clear() + else this.#listeners.delete(event) + return this + } + + write( + chunk: Uint8Array | string, + encodingOrCb?: string | ((error?: Error) => void), + cb?: (error?: Error) => void, + ): boolean { + const onDone = typeof encodingOrCb === "function" ? encodingOrCb : cb + try { + if (this.readyState !== "open" || this.#ws.readyState !== WebSocket.OPEN) { + const error = new Error("postgres websocket is not open") + onDone?.(error) + this.#emit("error", error) + return false + } + this.#ws.send(toSendable(typeof chunk === "string" ? chunk : new Uint8Array(chunk))) + onDone?.() + queueMicrotask(() => this.#emit("drain")) + return true + } catch (cause) { + const error = cause instanceof Error ? cause : new Error("postgres websocket write failed") + onDone?.(error) + this.#emit("error", error) + return false + } + } + + end(chunk?: Uint8Array | string): this { + if (chunk !== undefined) this.write(chunk) + this.destroy() + return this + } + + destroy(): void { + if (this.readyState === "closed") return + this.readyState = "closed" + try { + this.#ws.close() + } catch { + // already closing + } + } + + setNoDelay(_noDelay?: boolean): this { + return this + } + + setKeepAlive(_enable?: boolean, _initialDelay?: number): this { + return this + } + + setTimeout(_timeout?: number): this { + return this + } + + #emit(event: string, ...args: unknown[]): void { + const bucket = this.#listeners.get(event) + if (bucket === undefined) return + for (const listener of [...bucket]) listener(...args) + } +} + +const OPEN_TIMEOUT_MS = 5_000 + +const withTimeout = (promise: Promise, url: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`postgres websocket open timed out: ${url}`)), + OPEN_TIMEOUT_MS, + ) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) + +const waitUntilOpen = (ws: WebSocket, url: string): Promise => { + if (ws.readyState === WebSocket.OPEN) return Promise.resolve(ws) + if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return Promise.reject(new Error(`failed to open postgres websocket: ${url}`)) + } + return new Promise((resolve, reject) => { + const onOpen = () => { + cleanup() + resolve(ws) + } + const onError = () => { + cleanup() + reject(new Error(`failed to open postgres websocket: ${url}`)) + } + const cleanup = () => { + ws.removeEventListener("open", onOpen) + ws.removeEventListener("error", onError) + } + ws.addEventListener("open", onOpen) + ws.addEventListener("error", onError) + }) +} + +const httpUpgradeUrl = (wsUrl: string): string => { + if (wsUrl.startsWith("wss://")) return `https://${wsUrl.slice("wss://".length)}` + if (wsUrl.startsWith("ws://")) return `http://${wsUrl.slice("ws://".length)}` + return wsUrl +} + +const acceptWorkerSocket = (ws: WebSocket): WebSocket => { + const accept = (ws as WebSocket & { accept?: () => void }).accept + if (typeof accept === "function") accept.call(ws) + if ("binaryType" in ws) ws.binaryType = "arraybuffer" + return ws +} + +const connectViaConstructor = (url: string): Promise => { + const ws = new WebSocket(url) + if ("binaryType" in ws) ws.binaryType = "arraybuffer" + return waitUntilOpen(ws, url) +} + +const connectViaFetchUpgrade = async (wsUrl: string): Promise => { + const response = await fetch(httpUpgradeUrl(wsUrl), { headers: { Upgrade: "websocket" } }) + const candidate = (response as Response & { webSocket?: WebSocket | null }).webSocket + if (!candidate) { + throw new Error(`postgres websocket upgrade returned no socket: ${wsUrl}`) + } + return acceptWorkerSocket(candidate) +} + +/** + * Open `wsProxyUrl` and present a postgres.js-compatible duplex. + * + * workerd outbound is `fetch(http://…)` + `Upgrade: websocket` (never + * `fetch(ws://…)`, which hangs). `new WebSocket()` is the fallback for Node + * tests. Each attempt is bounded so a stall cannot eat postgres.js's + * connect_timeout (that timer starts only after this factory returns). + */ +export const openMaplePgWebSocket = async (wsProxyUrl: string): Promise => { + const ws = await connectWebSocket(wsProxyUrl) + return new MaplePgWsDuplex(ws) +} + +const connectWebSocket = async (url: string): Promise => { + try { + return await withTimeout(connectViaConstructor(url), url) + } catch { + return await withTimeout(connectViaFetchUpgrade(url), url) + } +} diff --git a/scripts/celld-dev.sh b/scripts/celld-dev.sh new file mode 100755 index 000000000..80a31914a --- /dev/null +++ b/scripts/celld-dev.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# Start Maple Cloud (API on celld + docker Postgres/ClickHouse) without wrangler. +# See docs/celld-self-host.md. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CELLD_VERSION="${CELLD_VERSION:-v0.4.0}" +TOOLS_DIR="$ROOT/.tools" +CELLD_BIN="${CELLD_BIN:-$TOOLS_DIR/celld}" +API_PORT="${API_PORT:-3472}" +WEB_PORT="${WEB_PORT:-3471}" +ELECTRIC_PORT="${ELECTRIC_PORT:-3476}" +ALERTING_PORT="${ALERTING_PORT:-8788}" +PG_PROXY_PORT="${PG_PROXY_PORT:-5498}" +PG_PORT="${PG_PORT:-5499}" +START_WEB="${START_WEB:-1}" +START_ELECTRIC="${START_ELECTRIC:-1}" +START_ALERTING="${START_ALERTING:-1}" +ENV_FILE="${ENV_FILE:-$ROOT/.env.local}" +VARS_FILE="$TOOLS_DIR/celld-vars.env" +PIDS=() + +log() { printf 'celld-dev: %s\n' "$*"; } +die() { printf 'celld-dev: %s\n' "$*" >&2; exit 1; } + +cleanup() { + local pid + for pid in "${PIDS[@]+"${PIDS[@]}"}"; do + kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT INT TERM + +port_pids() { + lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null || true +} + +port_cmd() { + local pid=$1 + ps -p "$pid" -o args= 2>/dev/null || true +} + +is_listening() { + [[ -n "$(port_pids "$1")" ]] +} + +wait_for_port() { + local port=$1 + local label=$2 + local attempts=${3:-40} + local i + for ((i = 0; i < attempts; i++)); do + if is_listening "$port"; then return 0; fi + sleep 0.25 + done + die "timed out waiting for $label on :$port" +} + +wait_for_port_soft() { + local port=$1 + local attempts=${2:-80} + local i + for ((i = 0; i < attempts; i++)); do + if is_listening "$port"; then return 0; fi + sleep 0.25 + done + return 1 +} + +free_port_from() { + local port=$1 + local i + for ((i = 0; i < 20; i++)); do + if ! is_listening "$port"; then + printf '%s\n' "$port" + return 0 + fi + port=$((port + 1)) + done + die "could not find a free port near $1" +} + +stop_ours_on_port() { + local port=$1 + local pid cmd + for pid in $(port_pids "$port"); do + cmd=$(port_cmd "$pid") + if [[ "$cmd" == *wrangler* && ( "$cmd" == *maple-api* || "$cmd" == *apps/api* || "$cmd" == *wrangler.jsonc* ) ]]; then + log "stopping maple wrangler on :$port (pid $pid)" + kill "$pid" 2>/dev/null || true + sleep 0.4 + elif [[ "$cmd" == *pg-ws-proxy* ]]; then + log "reusing pg-ws-proxy on :$port (pid $pid)" + return 1 + elif [[ "$cmd" == *celld* ]]; then + log "stopping previous celld on :$port (pid $pid)" + kill "$pid" 2>/dev/null || true + sleep 0.4 + fi + done + return 0 +} + +read_env_value() { + local key=$1 + local file=$2 + [[ -f "$file" ]] || return 0 + awk -F= -v k="$key" ' + $0 ~ /^[[:space:]]*#/ { next } + $0 ~ /^[[:space:]]*$/ { next } + index($0, "=") == 0 { next } + { + name = $1 + sub(/^[[:space:]]+/, "", name) + sub(/[[:space:]]+$/, "", name) + if (name == k) { + val = substr($0, index($0, "=") + 1) + sub(/\r$/, "", val) + if (val ~ /^".*"$/) val = substr(val, 2, length(val) - 2) + else if (val ~ /^'\''.*'\''$/) val = substr(val, 2, length(val) - 2) + print val + } + } + ' "$file" | tail -n 1 +} + +ensure_celld() { + local version_ok=0 + if [[ -x "$CELLD_BIN" ]] && "$CELLD_BIN" --version 2>/dev/null | grep -q "0.4.0"; then + version_ok=1 + elif command -v celld >/dev/null 2>&1 && celld --version 2>/dev/null | grep -q "0.4.0"; then + CELLD_BIN="$(command -v celld)" + version_ok=1 + fi + if [[ "$version_ok" -eq 1 ]]; then + log "using $CELLD_BIN ($("$CELLD_BIN" --version 2>/dev/null | head -n 1))" + return 0 + fi + local asset="celld-aarch64-apple-darwin.gz" + local url="https://github.com/denoland/celld/releases/download/${CELLD_VERSION}/${asset}" + log "installing celld ${CELLD_VERSION} → $CELLD_BIN" + mkdir -p "$TOOLS_DIR" + curl -fsSL "$url" | gzip -dc > "$CELLD_BIN" + chmod +x "$CELLD_BIN" + "$CELLD_BIN" --version >/dev/null +} + +ensure_esbuild() { + if command -v esbuild >/dev/null 2>&1; then return 0; fi + mkdir -p "$TOOLS_DIR" + cat > "$TOOLS_DIR/esbuild" <<'EOF' +#!/usr/bin/env bash +exec bun x --yes esbuild "$@" +EOF + chmod +x "$TOOLS_DIR/esbuild" + PATH="$TOOLS_DIR:$PATH" + export PATH + "$TOOLS_DIR/esbuild" --version >/dev/null || die "esbuild is required on PATH for celld" + log "esbuild → $TOOLS_DIR/esbuild (bun x)" +} + +write_vars_file() { + mkdir -p "$TOOLS_DIR" + local required=( + TINYBIRD_HOST + TINYBIRD_TOKEN + MAPLE_INGEST_KEY_ENCRYPTION_KEY + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY + MAPLE_ROOT_PASSWORD + ) + local optional=( + MAPLE_SHARE_TOKEN_HMAC_KEY + CLICKHOUSE_PASSWORD + CLICKHOUSE_USER + CLICKHOUSE_DATABASE + CLICKHOUSE_PROVIDER + MAPLE_AUTH_MODE + MAPLE_DEFAULT_ORG_ID + INTERNAL_SERVICE_TOKEN + SD_INTERNAL_TOKEN + MAPLE_ORG_ID_OVERRIDE + MAPLE_APP_BASE_URL + ) + local key value + : > "$VARS_FILE" + for key in "${required[@]}"; do + value="$(read_env_value "$key" "$ENV_FILE")" + [[ -n "$value" ]] || die "missing $key in $ENV_FILE (required for celld Env)" + printf '%s=%s\n' "$key" "$value" >> "$VARS_FILE" + done + for key in "${optional[@]}"; do + value="$(read_env_value "$key" "$ENV_FILE")" + if [[ -n "$value" ]]; then + printf '%s=%s\n' "$key" "$value" >> "$VARS_FILE" + fi + done + printf 'CLICKHOUSE_URL=%s\n' "${CLICKHOUSE_URL:-http://127.0.0.1:8123}" >> "$VARS_FILE" + printf 'CLICKHOUSE_PROVIDER=%s\n' "${CLICKHOUSE_PROVIDER:-clickhouse}" >> "$VARS_FILE" + printf 'MAPLE_PG_URL=%s\n' "${MAPLE_PG_URL:-postgres://maple:maple@127.0.0.1:${PG_PORT}/maple}" >> "$VARS_FILE" + printf 'MAPLE_PG_WS_PROXY=%s\n' "${MAPLE_PG_WS_PROXY:-ws://127.0.0.1:${PG_PROXY_PORT}}" >> "$VARS_FILE" + printf 'MAPLE_APP_BASE_URL=%s\n' "${MAPLE_APP_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" >> "$VARS_FILE" + printf 'ELECTRIC_URL=%s\n' "${ELECTRIC_URL:-http://127.0.0.1:3473}" >> "$VARS_FILE" + printf 'MAPLE_ALERTING_ALLOW_NONPROD=%s\n' "1" >> "$VARS_FILE" + log "wrote $VARS_FILE" +} + +apply_clickhouse_schema() { + local user password database + user="$(read_env_value CLICKHOUSE_USER "$ENV_FILE")" + password="$(read_env_value CLICKHOUSE_PASSWORD "$ENV_FILE")" + database="$(read_env_value CLICKHOUSE_DATABASE "$ENV_FILE")" + user="${user:-maple}" + password="${password:-maple}" + database="${database:-default}" + log "applying clickhouse schema" + bun run --cwd packages/clickhouse-cli start apply \ + --url=http://localhost:8123 \ + --user="$user" \ + --password="$password" \ + --database="$database" +} + +ensure_data_plane() { + if [[ "${CELLD_SKIP_DATA_PLANE:-0}" == "1" ]]; then + log "skipping docker data plane (CELLD_SKIP_DATA_PLANE=1)" + return 0 + fi + log "ensuring docker postgres/electric + clickhouse" + bun db:up + bun ch:up + wait_for_port "$PG_PORT" "postgres" + wait_for_port 8123 "clickhouse" + log "applying postgres migrations" + bun db:migrate:local + ensure_electric_publication + apply_clickhouse_schema +} + +# 0009 wraps CREATE PUBLICATION in WHEN OTHERS, so drizzle can mark it applied +# with an empty/partial publication. ELECTRIC_MANUAL_TABLE_PUBLISHING=true then +# 503s shapes for unpublished tables. Heal membership idempotently. +ensure_electric_publication() { + log "ensuring electric_publication_default tables" + docker compose -f docker-compose.development.yml exec -T postgres psql -U maple -d maple <<'SQL' +DO $$ +DECLARE + t text; +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'electric_publication_default') THEN + CREATE PUBLICATION electric_publication_default; + END IF; + FOREACH t IN ARRAY ARRAY['dashboards','alert_rules','alert_rule_states','alert_incidents','alert_destinations','api_keys'] LOOP + EXECUTE format('ALTER TABLE %I REPLICA IDENTITY FULL', t); + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'electric_publication_default' AND schemaname = 'public' AND tablename = t + ) THEN + EXECUTE format('ALTER PUBLICATION electric_publication_default ADD TABLE %I', t); + END IF; + END LOOP; +END $$; +SQL +} + +start_proxy() { + if is_listening "$PG_PROXY_PORT"; then + local pid cmd + pid="$(port_pids "$PG_PROXY_PORT" | head -n 1)" + cmd="$(port_cmd "$pid")" + if [[ "$cmd" == *pg-ws-proxy* ]]; then + log "pg-ws-proxy already on :$PG_PROXY_PORT" + return 0 + fi + log ":$PG_PROXY_PORT is busy; picking another listen port" + PG_PROXY_PORT="$(free_port_from $((PG_PROXY_PORT + 1)))" + fi + log "starting pg-ws-proxy on :$PG_PROXY_PORT → 127.0.0.1:$PG_PORT" + bun "$ROOT/scripts/pg-ws-proxy.ts" --listen "127.0.0.1:${PG_PROXY_PORT}" --target "127.0.0.1:${PG_PORT}" & + PIDS+=("$!") + wait_for_port "$PG_PROXY_PORT" "pg-ws-proxy" +} + +claim_api_port() { + if ! is_listening "$API_PORT"; then return 0; fi + if stop_ours_on_port "$API_PORT"; then + if is_listening "$API_PORT"; then + log ":$API_PORT still in use; picking another API port" + API_PORT="$(free_port_from $((API_PORT + 1)))" + log "API will listen on :$API_PORT — set VITE_API_BASE_URL=http://localhost:${API_PORT}" + fi + fi +} + +start_web() { + [[ "$START_WEB" == "1" ]] || return 0 + if is_listening "$WEB_PORT"; then + log "web already listening on :$WEB_PORT" + return 0 + fi + log "starting vite web on :$WEB_PORT against API :$API_PORT" + ( + cd "$ROOT" + export VITE_API_BASE_URL="http://localhost:${API_PORT}" + export VITE_MAPLE_AUTH_MODE="${VITE_MAPLE_AUTH_MODE:-self_hosted}" + bun --filter=@maple/web dev:app + ) & + PIDS+=("$!") +} + +# 0 = start a new celld, 1 = reuse existing celld, 2 = skip (port busy) +claim_worker_port() { + local port=$1 + local label=$2 + if ! is_listening "$port"; then return 0; fi + local pid cmd + pid="$(port_pids "$port" | head -n 1)" + cmd="$(port_cmd "$pid")" + if [[ "$cmd" == *celld* ]]; then + log "$label already on :$port (pid $pid)" + return 1 + fi + if [[ "$cmd" == *wrangler* ]]; then + log "stopping wrangler on :$port so $label can bind (pid $pid)" + kill "$pid" 2>/dev/null || true + sleep 0.4 + return 0 + fi + log ":$port is busy; skipping $label" + return 2 +} + +start_worker_celld() { + local dir=$1 + local port=$2 + local label=$3 + local required=${4:-0} + local logfile="$TOOLS_DIR/${label}.celld.log" + local claim=0 + claim_worker_port "$port" "$label" || claim=$? + if [[ "$claim" -eq 1 ]]; then return 0; fi + if [[ "$claim" -eq 2 ]]; then + [[ "$required" -eq 1 ]] && die "cannot bind $label on :$port" + return 0 + fi + log "starting $label celld on :$port" + ( + cd "$ROOT/$dir" + export CELLD_VARS_FILE="$VARS_FILE" + export PATH="$TOOLS_DIR:$PATH" + exec "$CELLD_BIN" dev wrangler.celld.jsonc --port "$port" + ) >"$logfile" 2>&1 & + PIDS+=("$!") + if wait_for_port_soft "$port" 80; then + log "$label ready on :$port" + return 0 + fi + log "$label did not bind :$port (see $logfile)" + if [[ "$required" -eq 1 ]]; then + tail -n 40 "$logfile" >&2 || true + die "$label celld failed to start" + fi +} + +start_electric() { + [[ "$START_ELECTRIC" == "1" ]] || return 0 + start_worker_celld "apps/electric-sync" "$ELECTRIC_PORT" "electric-sync" 1 +} + +start_alerting() { + [[ "$START_ALERTING" == "1" ]] || return 0 + # Alerting is scheduled-only (fetch is 404). Env rabbit holes must not + # take down api+electric; wrangler.celld.jsonc is still present. + start_worker_celld "apps/alerting" "$ALERTING_PORT" "alerting" 0 +} + +ensure_data_plane +ensure_celld +ensure_esbuild +start_proxy +claim_api_port +write_vars_file +start_electric +start_alerting +start_web + +log "celld API → http://127.0.0.1:${API_PORT}" +log "electric-sync → http://127.0.0.1:${ELECTRIC_PORT} (own celld project)" +log "alerting → http://127.0.0.1:${ALERTING_PORT} (scheduled; fetch 404)" +log "health → GET /health and GET /.well-known/celld/health" +log "web → http://127.0.0.1:${WEB_PORT} (START_WEB=0 to skip)" +export CELLD_VARS_FILE="$VARS_FILE" +export PATH="$TOOLS_DIR:$PATH" +cd "$ROOT/apps/api" +"$CELLD_BIN" dev wrangler.celld.jsonc --port "$API_PORT" --logs diff --git a/scripts/pg-ws-proxy.test.ts b/scripts/pg-ws-proxy.test.ts new file mode 100644 index 000000000..0b81333a1 --- /dev/null +++ b/scripts/pg-ws-proxy.test.ts @@ -0,0 +1,132 @@ +import { createServer } from "node:net" +import { describe, expect, it } from "bun:test" +import { startPgWsProxy } from "./pg-ws-proxy" + +const listenTcpEcho = async (): Promise<{ port: number; close: () => void }> => { + const server = createServer((socket) => { + socket.on("data", (chunk) => socket.write(chunk)) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (address === null || typeof address === "string") { + throw new Error("tcp echo did not bind a port") + } + return { port: address.port, close: () => server.close() } +} + +describe("pg-ws-proxy", () => { + it("executes SQL over POST /sql through host postgres.js", async () => { + const proxy = await startPgWsProxy({ + listenHost: "127.0.0.1", + listenPort: 0, + connectionString: "postgres://maple:maple@127.0.0.1:5499/maple", + }) + try { + const response = await fetch(`http://127.0.0.1:${proxy.listenPort}/sql`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sql: "select 1::int as x", params: [], method: "execute" }), + }) + const payload = (await response.json()) as { rows: Array<{ x: number }> } + expect(response.ok).toBe(true) + expect(payload.rows[0]?.x).toBe(1) + } finally { + proxy.stop() + } + }) + + it("upgrades /v1?address= to a raw byte pipe at the requested TCP target", async () => { + const echo = await listenTcpEcho() + const proxy = await startPgWsProxy({ + listenHost: "127.0.0.1", + listenPort: 0, + targetHost: "127.0.0.1", + targetPort: 1, + }) + try { + const ws = new WebSocket(`ws://127.0.0.1:${proxy.listenPort}/v1?address=127.0.0.1:${echo.port}`) + ws.binaryType = "arraybuffer" + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed to open")), { + once: true, + }) + }) + + const payload = new Uint8Array([9, 8, 7, 6]) + const reply = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no echo")), 2_000) + ws.addEventListener( + "message", + (event) => { + clearTimeout(timer) + resolve(new Uint8Array(event.data as ArrayBuffer)) + }, + { once: true }, + ) + }) + ws.send(payload) + expect(Array.from(await reply)).toEqual(Array.from(payload)) + ws.close() + } finally { + proxy.stop() + echo.close() + } + }) + + it("rejects a /v1 address that is not loopback or the configured target", async () => { + const proxy = await startPgWsProxy({ + listenHost: "127.0.0.1", + listenPort: 0, + targetHost: "127.0.0.1", + targetPort: 5499, + }) + try { + const response = await fetch(`http://127.0.0.1:${proxy.listenPort}/v1?address=example.com:5432`, { + headers: { Upgrade: "websocket" }, + }) + expect(response.status).toBe(403) + } finally { + proxy.stop() + } + }) + + it("copies binary frames between a websocket client and TCP postgres", async () => { + const echo = await listenTcpEcho() + const proxy = await startPgWsProxy({ + listenHost: "127.0.0.1", + listenPort: 0, + targetHost: "127.0.0.1", + targetPort: echo.port, + }) + try { + const ws = new WebSocket(proxy.url) + ws.binaryType = "arraybuffer" + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed to open")), { + once: true, + }) + }) + + const payload = new Uint8Array([0, 3, 0, 0, 81]) + const reply = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no echo")), 2_000) + ws.addEventListener( + "message", + (event) => { + clearTimeout(timer) + resolve(new Uint8Array(event.data as ArrayBuffer)) + }, + { once: true }, + ) + }) + ws.send(payload) + expect(Array.from(await reply)).toEqual(Array.from(payload)) + ws.close() + } finally { + proxy.stop() + echo.close() + } + }) +}) diff --git a/scripts/pg-ws-proxy.ts b/scripts/pg-ws-proxy.ts new file mode 100644 index 000000000..a308d749a --- /dev/null +++ b/scripts/pg-ws-proxy.ts @@ -0,0 +1,241 @@ +/** + * Host-side Postgres bridge for celld. + * + * celld cannot TCP-dial `localhost:5499`, and workerd postgres.js SCRAM against + * real Postgres fails. This process: + * - GET /v1?address=host:port (and /v2) — Neon-compatible WebSocket byte pipe + * (`@neondatabase/serverless` / `neondatabase/wsproxy`) + * - POST /sql — drizzle-orm/pg-proxy sidecar (tests / fallback only) + * - WebSocket on other paths — raw byte tunnel to the configured target (tests) + * + * Usage: + * bun scripts/pg-ws-proxy.ts + * bun scripts/pg-ws-proxy.ts --listen 127.0.0.1:5498 --target 127.0.0.1:5499 + */ +import postgres from "../packages/db/node_modules/postgres/src/index.js" + +export interface PgWsProxyOptions { + readonly listenHost?: string + readonly listenPort?: number + readonly targetHost?: string + readonly targetPort?: number + readonly connectionString?: string +} + +export interface PgWsProxyHandle { + readonly url: string + readonly listenHost: string + readonly listenPort: number + readonly stop: () => void +} + +type Tunnel = { + socket?: ReturnType extends Promise ? S : never + pending: Uint8Array[] + closed: boolean + targetHost: string + targetPort: number +} + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]) + +const parseAddressParam = (raw: string): { host: string; port: number } | undefined => { + const trimmed = raw.trim() + const colon = trimmed.lastIndexOf(":") + if (colon <= 0) return undefined + const host = trimmed.slice(0, colon) + const port = Number(trimmed.slice(colon + 1)) + if (host.length === 0 || !Number.isFinite(port) || port <= 0) return undefined + return { host, port } +} + +const extraAllowedHosts = (): ReadonlySet => { + const raw = process.env.MAPLE_PG_WS_ALLOW ?? "" + return new Set( + raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ) +} + +const isAllowedProxyAddress = (host: string, configuredHost: string): boolean => { + const normalized = host.replace(/^\[|\]$/g, "") + if (LOOPBACK_HOSTS.has(host) || LOOPBACK_HOSTS.has(normalized)) return true + if (host === configuredHost || normalized === configuredHost) return true + const extra = extraAllowedHosts() + return extra.has(host) || extra.has(normalized) +} + +const toBytes = (message: string | ArrayBuffer | Uint8Array): Uint8Array => { + if (typeof message === "string") return new TextEncoder().encode(message) + if (message instanceof Uint8Array) return message + return new Uint8Array(message) +} + +const parseHostPort = (raw: string | undefined, fallbackHost: string, fallbackPort: number) => { + if (raw === undefined || raw.trim().length === 0) { + return { host: fallbackHost, port: fallbackPort } + } + const trimmed = raw.trim() + const colon = trimmed.lastIndexOf(":") + if (colon <= 0) { + const port = Number(trimmed) + if (!Number.isFinite(port)) throw new Error(`invalid host:port ${raw}`) + return { host: fallbackHost, port } + } + const host = trimmed.slice(0, colon) + const port = Number(trimmed.slice(colon + 1)) + if (host.length === 0 || !Number.isFinite(port) || port <= 0) { + throw new Error(`invalid host:port ${raw}`) + } + return { host, port } +} + +export const startPgWsProxy = async (options: PgWsProxyOptions = {}): Promise => { + const listenHost = options.listenHost ?? "127.0.0.1" + const listenPort = options.listenPort ?? 5498 + const targetHost = options.targetHost ?? "127.0.0.1" + const targetPort = options.targetPort ?? 5499 + const connectionString = + options.connectionString ?? + process.env.MAPLE_PG_URL ?? + `postgres://maple:maple@${targetHost}:${targetPort}/maple` + const sql = postgres(connectionString, { max: 5, fetch_types: false, prepare: false }) + + const server = Bun.serve({ + hostname: listenHost, + port: listenPort, + async fetch(request, srv) { + const url = new URL(request.url) + if (request.method === "POST" && url.pathname === "/sql") { + try { + const body = (await request.json()) as { + readonly sql?: string + readonly params?: unknown[] + readonly method?: string + } + if (typeof body.sql !== "string" || body.sql.length === 0) { + return Response.json({ error: "sql is required" }, { status: 400 }) + } + const params = Array.isArray(body.params) ? body.params : [] + const query = sql.unsafe(body.sql, params as never[]) + const rows = body.method === "all" ? await query.values() : await query + return Response.json({ rows }) + } catch (cause) { + const message = cause instanceof Error ? cause.message : "postgres proxy query failed" + return Response.json({ error: message }, { status: 500 }) + } + } + let destHost = targetHost + let destPort = targetPort + if (url.pathname === "/v1" || url.pathname === "/v2") { + const address = url.searchParams.get("address") + if (address !== null && address.length > 0) { + const parsed = parseAddressParam(address) + if (parsed === undefined) { + return new Response("invalid address", { status: 400 }) + } + if (!isAllowedProxyAddress(parsed.host, targetHost)) { + return new Response("address not allowed", { status: 403 }) + } + destHost = parsed.host + destPort = parsed.port + } + } + if ( + srv.upgrade(request, { + data: { pending: [], closed: false, targetHost: destHost, targetPort: destPort }, + }) + ) { + return undefined + } + return new Response("Expected WebSocket or POST /sql", { status: 426 }) + }, + websocket: { + async open(ws) { + try { + const socket = await Bun.connect({ + hostname: ws.data.targetHost, + port: ws.data.targetPort, + socket: { + data(_sock, data) { + if (ws.data.closed) return + ws.send(data) + }, + error(_sock, error) { + if (ws.data.closed) return + ws.close(1011, error.message) + }, + close() { + if (ws.data.closed) return + ws.close(1000) + }, + }, + }) + if (ws.data.closed) { + socket.end() + return + } + ws.data.socket = socket + for (const chunk of ws.data.pending) socket.write(chunk) + ws.data.pending = [] + } catch (cause) { + const message = cause instanceof Error ? cause.message : "tcp connect failed" + ws.close(1011, message) + } + }, + message(ws, message) { + const bytes = toBytes(message) + const socket = ws.data.socket + if (socket === undefined) { + ws.data.pending.push(bytes) + return + } + socket.write(bytes) + }, + close(ws) { + ws.data.closed = true + ws.data.socket?.end() + }, + }, + }) + + return { + url: `ws://${listenHost}:${server.port}`, + listenHost, + listenPort: server.port, + stop: () => { + server.stop(true) + void sql.end({ timeout: 1 }).catch(() => undefined) + }, + } +} + +const parseArgs = (argv: string[]): PgWsProxyOptions => { + const options: { listen?: string; target?: string } = {} + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const next = argv[index + 1] + if (arg === "--listen" && next) { + options.listen = next + index += 1 + } else if (arg === "--target" && next) { + options.target = next + index += 1 + } + } + const listen = parseHostPort(options.listen ?? process.env.MAPLE_PG_WS_PROXY_LISTEN, "127.0.0.1", 5498) + const target = parseHostPort(options.target ?? process.env.MAPLE_PG_PROXY_TARGET, "127.0.0.1", 5499) + return { + listenHost: listen.host, + listenPort: listen.port, + targetHost: target.host, + targetPort: target.port, + } +} + +if (import.meta.main) { + const proxy = await startPgWsProxy(parseArgs(process.argv.slice(2))) + console.log(`pg-ws-proxy listening on ${proxy.url} → postgres`) +} From f9354d01ff2385fae49942d1f123f7d407026ec3 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 18:16:56 +0800 Subject: [PATCH 2/6] feat(deploy): complete celld k8s recipe without host-specific pins Ship Postgres/ClickHouse/Electric/OTel alongside the Workers, and run first boot as drizzle-kit + clickhouse-cli + db:ensure-electric-publication instead of SQL in a ConfigMap. Environment pins (nodeSelector, ingress) go in a Kustomize overlay. Default RUST_LOG=warn so celld stops emitting a ship-loop line every second. --- deploy/celld-self-host/compose.yml | 1 + .../docker/entrypoint-celld.sh | 2 + deploy/celld-self-host/k8s/alerting.yaml | 3 + deploy/celld-self-host/k8s/api.yaml | 10 ++ deploy/celld-self-host/k8s/caddy.yaml | 37 ++++-- deploy/celld-self-host/k8s/clickhouse.yaml | 73 +++++++++++ deploy/celld-self-host/k8s/electric.yaml | 48 +++++++ deploy/celld-self-host/k8s/kustomization.yaml | 11 +- deploy/celld-self-host/k8s/migrate.yaml | 37 ++++++ deploy/celld-self-host/k8s/otel.yaml | 120 ++++++++++++++++++ .../k8s/overlays/example/kustomization.yaml | 23 ++++ deploy/celld-self-host/k8s/postgres.yaml | 63 +++++++++ deploy/celld-self-host/k8s/proxy.yaml | 1 + deploy/celld-self-host/k8s/sync.yaml | 3 + deploy/celld-self-host/k8s/web.yaml | 1 + packages/db/package.json | 1 + .../db/scripts/ensure-electric-publication.ts | 56 ++++++++ packages/db/src/electric-publication.ts | 26 ++++ packages/db/src/migrations.test.ts | 17 +-- scripts/celld-dev.sh | 22 +--- 20 files changed, 509 insertions(+), 46 deletions(-) create mode 100644 deploy/celld-self-host/k8s/clickhouse.yaml create mode 100644 deploy/celld-self-host/k8s/electric.yaml create mode 100644 deploy/celld-self-host/k8s/migrate.yaml create mode 100644 deploy/celld-self-host/k8s/otel.yaml create mode 100644 deploy/celld-self-host/k8s/overlays/example/kustomization.yaml create mode 100644 deploy/celld-self-host/k8s/postgres.yaml create mode 100644 packages/db/scripts/ensure-electric-publication.ts create mode 100644 packages/db/src/electric-publication.ts diff --git a/deploy/celld-self-host/compose.yml b/deploy/celld-self-host/compose.yml index e049c3ac0..06116ce16 100644 --- a/deploy/celld-self-host/compose.yml +++ b/deploy/celld-self-host/compose.yml @@ -21,6 +21,7 @@ x-celld: &celld CELLD_VARS_FILE: /run/maple/celld-vars.env CELLD_WATCH: /var/lib/celld CELLD_TRUST_FORWARDED_HEADERS: "1" + RUST_LOG: ${RUST_LOG:-warn} S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} AWS_REGION: ${AWS_REGION:-us-east-1} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-maplecelld} diff --git a/deploy/celld-self-host/docker/entrypoint-celld.sh b/deploy/celld-self-host/docker/entrypoint-celld.sh index de8138219..b5df77dbe 100755 --- a/deploy/celld-self-host/docker/entrypoint-celld.sh +++ b/deploy/celld-self-host/docker/entrypoint-celld.sh @@ -20,6 +20,8 @@ if [[ -z "${CELLD_ESBUILD}" ]]; then fi export CELLD_TRUST_FORWARDED_HEADERS="${CELLD_TRUST_FORWARDED_HEADERS:-1}" export CELLD_WATCH="${CELLD_WATCH:-/var/lib/celld}" +# celld defaults RUST_LOG=info and emits a ship-loop / lease line every second. +export RUST_LOG="${RUST_LOG:-warn}" mkdir -p "$CELLD_WATCH" echo "celld-entrypoint: deploy $CONFIG → $BUCKET ($ENDPOINT)" diff --git a/deploy/celld-self-host/k8s/alerting.yaml b/deploy/celld-self-host/k8s/alerting.yaml index 5678e9f71..764b68362 100644 --- a/deploy/celld-self-host/k8s/alerting.yaml +++ b/deploy/celld-self-host/k8s/alerting.yaml @@ -16,6 +16,7 @@ spec: containers: - name: celld image: maple-celld:dev + imagePullPolicy: IfNotPresent env: - name: CELLD_APP_DIR value: /app/apps/alerting @@ -43,6 +44,8 @@ spec: key: AWS_SECRET_ACCESS_KEY - name: AWS_EC2_METADATA_DISABLED value: "true" + - name: RUST_LOG + value: warn volumeMounts: - name: vars mountPath: /run/maple diff --git a/deploy/celld-self-host/k8s/api.yaml b/deploy/celld-self-host/k8s/api.yaml index bac7052f9..db02c74a6 100644 --- a/deploy/celld-self-host/k8s/api.yaml +++ b/deploy/celld-self-host/k8s/api.yaml @@ -16,6 +16,7 @@ spec: containers: - name: celld image: maple-celld:dev + imagePullPolicy: IfNotPresent args: [] env: - name: CELLD_APP_DIR @@ -49,6 +50,8 @@ spec: value: "true" - name: CELLD_TRUST_FORWARDED_HEADERS value: "1" + - name: RUST_LOG + value: warn volumeMounts: - name: vars mountPath: /run/maple @@ -56,6 +59,13 @@ spec: mountPath: /var/lib/celld ports: - containerPort: 3472 + readinessProbe: + httpGet: + path: /health + port: 3472 + initialDelaySeconds: 20 + periodSeconds: 5 + failureThreshold: 24 volumes: - name: vars secret: diff --git a/deploy/celld-self-host/k8s/caddy.yaml b/deploy/celld-self-host/k8s/caddy.yaml index 64292cfbf..088e1e6b1 100644 --- a/deploy/celld-self-host/k8s/caddy.yaml +++ b/deploy/celld-self-host/k8s/caddy.yaml @@ -6,15 +6,31 @@ metadata: data: Caddyfile: | :80 { - encode gzip - handle /health { reverse_proxy api:3472 } - handle /.well-known/celld/* { reverse_proxy api:3472 } - handle /v2/* { reverse_proxy api:3472 } - handle /internal/* { reverse_proxy api:3472 } - handle /api/sync/* { reverse_proxy sync:3476 } - handle /api/* { reverse_proxy api:3472 } - handle /v1/* { reverse_proxy otel:4318 } - handle { reverse_proxy web:80 } + encode gzip + handle /health { + reverse_proxy api:3472 + } + handle /.well-known/celld/* { + reverse_proxy api:3472 + } + handle /v2/* { + reverse_proxy api:3472 + } + handle /internal/* { + reverse_proxy api:3472 + } + handle /api/sync/* { + reverse_proxy sync:3476 + } + handle /api/* { + reverse_proxy api:3472 + } + handle /v1/* { + reverse_proxy otel:4318 + } + handle { + reverse_proxy web:80 + } } --- apiVersion: apps/v1 @@ -58,6 +74,3 @@ spec: - name: http port: 80 targetPort: 80 - - name: otlp-grpc - port: 4317 - targetPort: 4317 diff --git a/deploy/celld-self-host/k8s/clickhouse.yaml b/deploy/celld-self-host/k8s/clickhouse.yaml new file mode 100644 index 000000000..42f4a509a --- /dev/null +++ b/deploy/celld-self-host/k8s/clickhouse.yaml @@ -0,0 +1,73 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: maple-clickhouse + namespace: maple +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-clickhouse + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-clickhouse + template: + metadata: + labels: + app: maple-clickhouse + spec: + containers: + - name: clickhouse + image: clickhouse/clickhouse-server:26.2 + env: + - name: CLICKHOUSE_DB + value: default + - name: CLICKHOUSE_USER + value: maple + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: maple-selfhost + key: CLICKHOUSE_PASSWORD + - name: CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT + value: "1" + ports: + - containerPort: 8123 + - containerPort: 9000 + volumeMounts: + - name: data + mountPath: /var/lib/clickhouse + readinessProbe: + httpGet: + path: /ping + port: 8123 + initialDelaySeconds: 10 + periodSeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: maple-clickhouse +--- +apiVersion: v1 +kind: Service +metadata: + name: clickhouse + namespace: maple +spec: + selector: + app: maple-clickhouse + ports: + - name: http + port: 8123 + targetPort: 8123 + - name: native + port: 9000 + targetPort: 9000 diff --git a/deploy/celld-self-host/k8s/electric.yaml b/deploy/celld-self-host/k8s/electric.yaml new file mode 100644 index 000000000..1ff7fea0e --- /dev/null +++ b/deploy/celld-self-host/k8s/electric.yaml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-electric + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-electric + template: + metadata: + labels: + app: maple-electric + spec: + enableServiceLinks: false + containers: + - name: electric + image: electricsql/electric:latest + env: + - name: DATABASE_URL + value: postgresql://maple:maple@postgres:5432/maple?sslmode=disable + - name: ELECTRIC_INSECURE + value: "true" + - name: ELECTRIC_MANUAL_TABLE_PUBLISHING + value: "true" + - name: ELECTRIC_PORT + value: "3000" + ports: + - containerPort: 3000 + readinessProbe: + httpGet: + path: /v1/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: electric + namespace: maple +spec: + selector: + app: maple-electric + ports: + - port: 3000 + targetPort: 3000 diff --git a/deploy/celld-self-host/k8s/kustomization.yaml b/deploy/celld-self-host/k8s/kustomization.yaml index 2f91319ba..3d3bad210 100644 --- a/deploy/celld-self-host/k8s/kustomization.yaml +++ b/deploy/celld-self-host/k8s/kustomization.yaml @@ -4,12 +4,21 @@ namespace: maple resources: - namespace.yaml - configmap.yaml + - postgres.yaml + - clickhouse.yaml + - electric.yaml - minio.yaml + - otel.yaml - proxy.yaml + - migrate.yaml - api.yaml - sync.yaml - alerting.yaml - web.yaml - caddy.yaml # Own S3: delete minio.yaml and set CELLD_BUCKET / S3_ENDPOINT / AWS_* on the -# celld Deployments. Own Postgres/CH/Electric: set URLs on maple-selfhost ConfigMap. +# celld Deployments. Own Postgres/CH/Electric: delete those yamls and set URLs +# on maple-selfhost ConfigMap. +# +# Environment pins (nodeSelector, image names, ingress host) belong in a +# Kustomize overlay, not in these files — see overlays/example. diff --git a/deploy/celld-self-host/k8s/migrate.yaml b/deploy/celld-self-host/k8s/migrate.yaml new file mode 100644 index 000000000..a36e70f93 --- /dev/null +++ b/deploy/celld-self-host/k8s/migrate.yaml @@ -0,0 +1,37 @@ +# First boot: drizzle-kit migrate + ClickHouse schema + Electric publication heal. +# Re-run: kubectl -n maple delete job maple-migrate && kubectl apply -k deploy/celld-self-host/k8s +apiVersion: batch/v1 +kind: Job +metadata: + name: maple-migrate + namespace: maple +spec: + backoffLimit: 12 + template: + spec: + restartPolicy: OnFailure + containers: + - name: migrate + image: maple-celld:dev + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: + name: maple-selfhost + - secretRef: + name: maple-selfhost + env: + - name: DATABASE_URL + value: postgres://maple:maple@postgres:5432/maple + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + export PATH="/usr/local/bin:/root/.bun/bin:$PATH" + cd /app + bun run --cwd packages/db db:migrate + bun run --cwd packages/clickhouse-cli start apply \ + --url="$CLICKHOUSE_URL" \ + --user="$CLICKHOUSE_USER" \ + --password="$CLICKHOUSE_PASSWORD" \ + --database="$CLICKHOUSE_DATABASE" + bun run --cwd packages/db db:ensure-electric-publication diff --git a/deploy/celld-self-host/k8s/otel.yaml b/deploy/celld-self-host/k8s/otel.yaml new file mode 100644 index 000000000..150a8827b --- /dev/null +++ b/deploy/celld-self-host/k8s/otel.yaml @@ -0,0 +1,120 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: maple-otel + namespace: maple +data: + config.yaml: | + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + processors: + batch: + timeout: 1s + send_batch_size: 5000 + send_batch_max_size: 10000 + memory_limiter: + check_interval: 1s + limit_mib: 512 + spike_limit_mib: 128 + exporters: + maple: + endpoint: http://clickhouse:8123 + database: default + username: maple + password: ${env:MAPLE_CLICKHOUSE_PASSWORD} + org_id: default + timeout: 30s + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 300s + sending_queue: + enabled: true + num_consumers: 4 + queue_size: 5000 + extensions: + health_check: + endpoint: 0.0.0.0:13133 + service: + extensions: [health_check] + pipelines: + logs: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [maple] + telemetry: + logs: + level: info +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-otel + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-otel + template: + metadata: + labels: + app: maple-otel + spec: + containers: + - name: otel + image: maple-otel:dev + imagePullPolicy: IfNotPresent + env: + - name: MAPLE_CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: maple-selfhost + key: CLICKHOUSE_PASSWORD + ports: + - containerPort: 4317 + - containerPort: 4318 + - containerPort: 13133 + volumeMounts: + - name: config + mountPath: /etc/otel + readinessProbe: + httpGet: + path: / + port: 13133 + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: config + configMap: + name: maple-otel +--- +apiVersion: v1 +kind: Service +metadata: + name: otel + namespace: maple +spec: + selector: + app: maple-otel + ports: + - name: otlp-grpc + port: 4317 + targetPort: 4317 + - name: otlp-http + port: 4318 + targetPort: 4318 diff --git a/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml b/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml new file mode 100644 index 000000000..d941d9834 --- /dev/null +++ b/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml @@ -0,0 +1,23 @@ +# Copy this overlay and replace YOUR_NODE. Kustomize is the overlay mechanism +# (not Helm, not envsubst): base YAML stays generic; environment pins live here. +# +# kubectl apply -k deploy/celld-self-host/k8s/overlays/example +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../.. +patches: + - target: + kind: Deployment + patch: |- + - op: add + path: /spec/template/spec/nodeSelector + value: + kubernetes.io/hostname: YOUR_NODE + - target: + kind: Job + patch: |- + - op: add + path: /spec/template/spec/nodeSelector + value: + kubernetes.io/hostname: YOUR_NODE diff --git a/deploy/celld-self-host/k8s/postgres.yaml b/deploy/celld-self-host/k8s/postgres.yaml new file mode 100644 index 000000000..b045bbc97 --- /dev/null +++ b/deploy/celld-self-host/k8s/postgres.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: maple-postgres + namespace: maple +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: maple-postgres + namespace: maple +spec: + replicas: 1 + selector: + matchLabels: + app: maple-postgres + template: + metadata: + labels: + app: maple-postgres + spec: + containers: + - name: postgres + image: postgres:17-alpine + args: ["postgres", "-c", "wal_level=logical"] + env: + - name: POSTGRES_USER + value: maple + - name: POSTGRES_PASSWORD + value: maple + - name: POSTGRES_DB + value: maple + ports: + - containerPort: 5432 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "maple", "-d", "maple"] + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: data + persistentVolumeClaim: + claimName: maple-postgres +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: maple +spec: + selector: + app: maple-postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/deploy/celld-self-host/k8s/proxy.yaml b/deploy/celld-self-host/k8s/proxy.yaml index 02c180a62..6e47e96fc 100644 --- a/deploy/celld-self-host/k8s/proxy.yaml +++ b/deploy/celld-self-host/k8s/proxy.yaml @@ -16,6 +16,7 @@ spec: containers: - name: proxy image: maple-pg-ws-proxy:dev + imagePullPolicy: IfNotPresent envFrom: - configMapRef: name: maple-selfhost diff --git a/deploy/celld-self-host/k8s/sync.yaml b/deploy/celld-self-host/k8s/sync.yaml index 88990e95a..d30f19a15 100644 --- a/deploy/celld-self-host/k8s/sync.yaml +++ b/deploy/celld-self-host/k8s/sync.yaml @@ -16,6 +16,7 @@ spec: containers: - name: celld image: maple-celld:dev + imagePullPolicy: IfNotPresent env: - name: CELLD_APP_DIR value: /app/apps/electric-sync @@ -43,6 +44,8 @@ spec: key: AWS_SECRET_ACCESS_KEY - name: AWS_EC2_METADATA_DISABLED value: "true" + - name: RUST_LOG + value: warn volumeMounts: - name: vars mountPath: /run/maple diff --git a/deploy/celld-self-host/k8s/web.yaml b/deploy/celld-self-host/k8s/web.yaml index 1789377e5..a4b72e758 100644 --- a/deploy/celld-self-host/k8s/web.yaml +++ b/deploy/celld-self-host/k8s/web.yaml @@ -16,6 +16,7 @@ spec: containers: - name: nginx image: maple-web:dev + imagePullPolicy: IfNotPresent ports: - containerPort: 80 --- diff --git a/packages/db/package.json b/packages/db/package.json index 5f8181bda..102f03d3b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,6 +14,7 @@ "test": "vitest run --passWithNoTests", "db:generate": "drizzle-kit generate --config ./drizzle.config.ts", "db:migrate": "drizzle-kit migrate --config ./drizzle.config.ts", + "db:ensure-electric-publication": "bun scripts/ensure-electric-publication.ts", "db:push": "drizzle-kit push --config ./drizzle.config.ts", "db:studio": "drizzle-kit studio --config ./drizzle.config.ts", "db:ensure-privileges": "bun scripts/ensure-privileges.ts", diff --git a/packages/db/scripts/ensure-electric-publication.ts b/packages/db/scripts/ensure-electric-publication.ts new file mode 100644 index 000000000..c79022d29 --- /dev/null +++ b/packages/db/scripts/ensure-electric-publication.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env bun +/** + * Idempotent heal for `electric_publication_default`. + * + * Drizzle 0009 wraps CREATE PUBLICATION in WHEN OTHERS, so a migrate can record + * as applied while the publication is empty. Electric then 503s unpublished + * shapes. Call this after `db:migrate` from celld-dev, Compose, or the k8s Job. + * + * DATABASE_URL=postgres://… bun run --cwd packages/db db:ensure-electric-publication + */ +import postgres from "postgres" +import { ELECTRIC_PUBLICATION, ELECTRIC_SYNCED_TABLES } from "../src/electric-publication" + +const url = process.env.DATABASE_URL?.trim() || process.env.MAPLE_PG_URL?.trim() +if (!url) { + throw new Error("DATABASE_URL or MAPLE_PG_URL is required") +} + +const quoteIdent = (name: string) => `"${name.replaceAll('"', "")}"` + +const sql = postgres(url) +try { + const existing = await sql` + select 1 from pg_publication where pubname = ${ELECTRIC_PUBLICATION} limit 1 + ` + if (existing.length === 0) { + await sql.unsafe(`CREATE PUBLICATION ${quoteIdent(ELECTRIC_PUBLICATION)}`) + } + + for (const table of ELECTRIC_SYNCED_TABLES) { + await sql.unsafe(`ALTER TABLE ${quoteIdent(table)} REPLICA IDENTITY FULL`) + const member = await sql` + select 1 from pg_publication_tables + where pubname = ${ELECTRIC_PUBLICATION} + and schemaname = 'public' + and tablename = ${table} + limit 1 + ` + if (member.length === 0) { + await sql.unsafe( + `ALTER PUBLICATION ${quoteIdent(ELECTRIC_PUBLICATION)} ADD TABLE ${quoteIdent(table)}`, + ) + } + } + + const members = await sql<{ tablename: string }>` + select tablename from pg_publication_tables + where pubname = ${ELECTRIC_PUBLICATION} + order by 1 + ` + console.log( + `electric publication ${ELECTRIC_PUBLICATION}: ${members.map((row) => row.tablename).join(", ")}`, + ) +} finally { + await sql.end() +} diff --git a/packages/db/src/electric-publication.ts b/packages/db/src/electric-publication.ts new file mode 100644 index 000000000..df11d67a6 --- /dev/null +++ b/packages/db/src/electric-publication.ts @@ -0,0 +1,26 @@ +/** + * Tables Maple serves as ElectricSQL shapes. Keep this list in step with the + * drizzle publication migrations (0009 / 0011 / 0014 / 0022 prune / 0037) and + * with `apps/electric-sync` shape names. `ensure-electric-publication.ts` and + * the bundled-migration test both import it so a YAML Job cannot drift. + */ +export const ELECTRIC_PUBLICATION = "electric_publication_default" + +export const ELECTRIC_SYNCED_TABLES = [ + "dashboards", + "alert_rules", + "alert_rule_states", + "alert_incidents", + "alert_destinations", + "api_keys", + "investigations", + "investigation_lens_runs", +] as const + +/** Published by 0009/0011, then dropped by 0022 once client collections went away. */ +export const ELECTRIC_UNSYNCED_TABLES = [ + "error_issues", + "actors", + "error_incidents", + "scrape_target_checks", +] as const diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts index 856495d7c..3f9a4c1fc 100644 --- a/packages/db/src/migrations.test.ts +++ b/packages/db/src/migrations.test.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import { PGlite } from "@electric-sql/pglite" import { describe, expect, it } from "vitest" +import { ELECTRIC_SYNCED_TABLES, ELECTRIC_UNSYNCED_TABLES } from "./electric-publication" import { readBundledMigrationsSql } from "./migrate" type MigrationJournal = { @@ -82,24 +83,12 @@ describe("drizzle migrations", () => { // WASM engine + replaying every migration is ~5s on CI runners (well over // vitest's 5s default), so the whole `it` is bounded at 30s. describe("bundled migrations", () => { - const SYNCED_TABLES = [ - "dashboards", - "alert_rules", - "alert_rule_states", - "alert_incidents", - // Wave 1 (0011_electric_publication_wave1) - "alert_destinations", - // API-key live reads (0014_electric_publication_api_keys) - "api_keys", - // Investigation detail page (0037_electric_publication_investigations) - "investigations", - "investigation_lens_runs", - ] + const SYNCED_TABLES = [...ELECTRIC_SYNCED_TABLES] // Published by 0009/0011, then pruned by 0022 once their client collections were // removed. Asserted explicitly so re-adding a table to the publication without a // consumer (or a prune migration that silently no-ops) fails here. - const UNSYNCED_TABLES = ["error_issues", "actors", "error_incidents", "scrape_target_checks"] + const UNSYNCED_TABLES = [...ELECTRIC_UNSYNCED_TABLES] it("apply cleanly and create the Electric publication with REPLICA IDENTITY FULL", async () => { const pg = new PGlite() diff --git a/scripts/celld-dev.sh b/scripts/celld-dev.sh index 80a31914a..aeaf24bd5 100755 --- a/scripts/celld-dev.sh +++ b/scripts/celld-dev.sh @@ -243,25 +243,8 @@ ensure_data_plane() { # 503s shapes for unpublished tables. Heal membership idempotently. ensure_electric_publication() { log "ensuring electric_publication_default tables" - docker compose -f docker-compose.development.yml exec -T postgres psql -U maple -d maple <<'SQL' -DO $$ -DECLARE - t text; -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'electric_publication_default') THEN - CREATE PUBLICATION electric_publication_default; - END IF; - FOREACH t IN ARRAY ARRAY['dashboards','alert_rules','alert_rule_states','alert_incidents','alert_destinations','api_keys'] LOOP - EXECUTE format('ALTER TABLE %I REPLICA IDENTITY FULL', t); - IF NOT EXISTS ( - SELECT 1 FROM pg_publication_tables - WHERE pubname = 'electric_publication_default' AND schemaname = 'public' AND tablename = t - ) THEN - EXECUTE format('ALTER PUBLICATION electric_publication_default ADD TABLE %I', t); - END IF; - END LOOP; -END $$; -SQL + DATABASE_URL="postgres://maple:maple@127.0.0.1:${PG_PORT}/maple" \ + bun run --cwd packages/db db:ensure-electric-publication } start_proxy() { @@ -349,6 +332,7 @@ start_worker_celld() { cd "$ROOT/$dir" export CELLD_VARS_FILE="$VARS_FILE" export PATH="$TOOLS_DIR:$PATH" + export RUST_LOG="${RUST_LOG:-warn}" exec "$CELLD_BIN" dev wrangler.celld.jsonc --port "$port" ) >"$logfile" 2>&1 & PIDS+=("$!") From 176177221b6de7cfa91d5bdab02b9410233377a7 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 18:22:40 +0800 Subject: [PATCH 3/6] feat(deploy): run Compose first-boot migrate via the same db scripts api/sync/alerting wait for a one-shot migrate service that calls drizzle-kit, clickhouse-cli, and db:ensure-electric-publication. The example k8s overlay uses strategic merge so re-apply is idempotent. --- deploy/celld-self-host/compose.yml | 33 +++++++++++++++++++ .../k8s/overlays/example/kustomization.yaml | 29 +++++++++++----- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/deploy/celld-self-host/compose.yml b/deploy/celld-self-host/compose.yml index 06116ce16..a60bfdac3 100644 --- a/deploy/celld-self-host/compose.yml +++ b/deploy/celld-self-host/compose.yml @@ -170,6 +170,33 @@ services: command: ["bun", "pg-ws-proxy.ts", "--listen", "0.0.0.0:5498", "--target", "postgres:5432"] restart: unless-stopped + migrate: + <<: *celld + restart: "no" + volumes: [] + entrypoint: ["/bin/sh", "-c"] + environment: + DATABASE_URL: ${MAPLE_PG_URL:-postgres://maple:maple@postgres:5432/maple} + CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-maple} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-maple} + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-default} + command: + - | + set -euo pipefail + export PATH="/usr/local/bin:/root/.bun/bin:$$PATH" + cd /app + bun run --cwd packages/db db:migrate + bun run --cwd packages/clickhouse-cli start apply \ + --url="$$CLICKHOUSE_URL" --user="$$CLICKHOUSE_USER" \ + --password="$$CLICKHOUSE_PASSWORD" --database="$$CLICKHOUSE_DATABASE" + bun run --cwd packages/db db:ensure-electric-publication + depends_on: + postgres: + condition: service_healthy + clickhouse: + condition: service_healthy + api: <<: *celld environment: @@ -193,6 +220,8 @@ services: condition: service_started pg-ws-proxy: condition: service_started + migrate: + condition: service_completed_successfully minio-init: condition: service_completed_successfully required: false @@ -219,6 +248,8 @@ services: depends_on: vars: condition: service_started + migrate: + condition: service_completed_successfully minio-init: condition: service_completed_successfully required: false @@ -244,6 +275,8 @@ services: condition: service_started pg-ws-proxy: condition: service_started + migrate: + condition: service_completed_successfully minio-init: condition: service_completed_successfully required: false diff --git a/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml b/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml index d941d9834..94b816115 100644 --- a/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml +++ b/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml @@ -1,6 +1,9 @@ # Copy this overlay and replace YOUR_NODE. Kustomize is the overlay mechanism # (not Helm, not envsubst): base YAML stays generic; environment pins live here. # +# Strategic merge (not JSON `op: add`) so re-apply is idempotent when the +# nodeSelector is already present. +# # kubectl apply -k deploy/celld-self-host/k8s/overlays/example apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -10,14 +13,24 @@ patches: - target: kind: Deployment patch: |- - - op: add - path: /spec/template/spec/nodeSelector - value: - kubernetes.io/hostname: YOUR_NODE + apiVersion: apps/v1 + kind: Deployment + metadata: + name: unused + spec: + template: + spec: + nodeSelector: + kubernetes.io/hostname: YOUR_NODE - target: kind: Job patch: |- - - op: add - path: /spec/template/spec/nodeSelector - value: - kubernetes.io/hostname: YOUR_NODE + apiVersion: batch/v1 + kind: Job + metadata: + name: unused + spec: + template: + spec: + nodeSelector: + kubernetes.io/hostname: YOUR_NODE From 1af09f02ed88928ac8e1e69b668788fb137a9ac2 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 18:33:25 +0800 Subject: [PATCH 4/6] fix(web): stop calling Clerk hooks in self-hosted settings and ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted has no ClerkProvider. Settings → Ingestion used useAuth via useGuidedFramework, which threw. Read org id from useActiveOrgId instead. schemaDiff returns an empty diff when BYO ClickHouse is not configured instead of 400. --- .../src/services/org/OrgClickHouseSettingsService.ts | 10 +++++++++- .../web/src/components/dashboard/first-action-hint.tsx | 4 ++-- apps/web/src/components/dashboard/setup-checklist.tsx | 6 +++--- apps/web/src/components/ingest/guided-setup.tsx | 5 ++--- apps/web/src/routes/quick-start.tsx | 9 +++++++++ 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/api/src/services/org/OrgClickHouseSettingsService.ts b/apps/api/src/services/org/OrgClickHouseSettingsService.ts index 3fcbd4e57..84352ac6b 100644 --- a/apps/api/src/services/org/OrgClickHouseSettingsService.ts +++ b/apps/api/src/services/org/OrgClickHouseSettingsService.ts @@ -1219,7 +1219,15 @@ export class OrgClickHouseSettingsService extends Context.Service< ) { yield* Effect.annotateCurrentSpan("orgId", orgId) yield* requireAdmin(roles) - const row = yield* requireActiveRow(orgId) + const existing = yield* selectActiveRow(orgId) + if (Option.isNone(existing)) { + return new OrgClickHouseSchemaDiffResponse({ + expectedSchemaVersion: clickHouseSchemaVersion, + appliedSchemaVersion: null, + entries: [], + }) + } + const row = existing.value const config = yield* loadConfigForRow(row) const actual = yield* fetchActualSchema(httpClient, config) const entries = computeSchemaDiff({ tables: yield* getDesiredTables }, actual) diff --git a/apps/web/src/components/dashboard/first-action-hint.tsx b/apps/web/src/components/dashboard/first-action-hint.tsx index 88df7a032..d9da77c3d 100644 --- a/apps/web/src/components/dashboard/first-action-hint.tsx +++ b/apps/web/src/components/dashboard/first-action-hint.tsx @@ -1,13 +1,13 @@ -import { useAuth } from "@clerk/clerk-react" import { useNavigate } from "@tanstack/react-router" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { Card, CardContent } from "@maple/ui/components/ui/card" import { Button } from "@maple/ui/components/ui/button" import { ChartLineIcon, RocketIcon, XmarkIcon } from "@/components/icons" import { useQuickStart } from "@/hooks/use-quick-start" +import { useActiveOrgId } from "@/lib/collections/org-collections" export function FirstActionHint() { - const { orgId } = useAuth() + const orgId = useActiveOrgId() const navigate = useNavigate() const reduceMotion = useReducedMotion() const { demoDataRequested, firstActionHintDismissed, dismissFirstActionHint } = useQuickStart(orgId) diff --git a/apps/web/src/components/dashboard/setup-checklist.tsx b/apps/web/src/components/dashboard/setup-checklist.tsx index 0a709ceaa..cff8143ef 100644 --- a/apps/web/src/components/dashboard/setup-checklist.tsx +++ b/apps/web/src/components/dashboard/setup-checklist.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@clerk/clerk-react" import { useNavigate } from "@tanstack/react-router" import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" @@ -15,9 +14,10 @@ import { GuidedSetup } from "@/components/ingest/guided-setup" import { SendTestEventStrip } from "@/components/ingest/connection-status" import { useIngestConnection } from "@/components/ingest/use-ingest-connection" import { useQuickStart } from "@/hooks/use-quick-start" +import { useActiveOrgId } from "@/lib/collections/org-collections" export function SetupChecklist() { - const { orgId } = useAuth() + const orgId = useActiveOrgId() const { checklistDismissed } = useQuickStart(orgId) // Render nothing — and stop polling — once the checklist is dismissed. @@ -27,7 +27,7 @@ export function SetupChecklist() { } function SetupChecklistCard() { - const { orgId } = useAuth() + const orgId = useActiveOrgId() const { dismissChecklist, checklistExpanded, setChecklistExpanded, demoDataRequested } = useQuickStart(orgId) diff --git a/apps/web/src/components/ingest/guided-setup.tsx b/apps/web/src/components/ingest/guided-setup.tsx index 5124bd126..fe55b9743 100644 --- a/apps/web/src/components/ingest/guided-setup.tsx +++ b/apps/web/src/components/ingest/guided-setup.tsx @@ -1,5 +1,3 @@ -import { useAuth } from "@clerk/clerk-react" - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" import { cn } from "@maple/ui/lib/utils" import { CodeBlock } from "@/components/quick-start/code-block" @@ -15,6 +13,7 @@ import { import { sdkSnippets, type FrameworkId } from "@/components/quick-start/sdk-snippets" import { ingestUrl } from "@/lib/services/common/ingest-url" import { useQuickStart } from "@/hooks/use-quick-start" +import { useActiveOrgId } from "@/lib/collections/org-collections" import type { RoleOption } from "@/atoms/quick-start-atoms" import { CopyableField } from "@maple/ui/components/ui/copyable-field" @@ -47,7 +46,7 @@ interface GuidedSetupProps { * settings page (which composes the picker into its own section header). */ export function useGuidedFramework() { - const { orgId } = useAuth() + const orgId = useActiveOrgId() const { selectedFramework, setSelectedFramework, qualifyAnswers } = useQuickStart(orgId) const roleDefault = qualifyAnswers.role ? ROLE_DEFAULT_FRAMEWORK[qualifyAnswers.role] : "nodejs" diff --git a/apps/web/src/routes/quick-start.tsx b/apps/web/src/routes/quick-start.tsx index 97429de29..5d598bc4a 100644 --- a/apps/web/src/routes/quick-start.tsx +++ b/apps/web/src/routes/quick-start.tsx @@ -13,6 +13,7 @@ import { StepDemo } from "@/components/onboarding/step-demo" import { useQuickStart, type StepId } from "@/hooks/use-quick-start" import { hasSelectedPlan, resolvePlanAccess } from "@/lib/billing/plan-gating" +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { STEP_IDS, type RoleOption } from "@/atoms/quick-start-atoms" const QuickStartSearch = Schema.Struct({ @@ -33,6 +34,14 @@ export const STEP_MOTION = { } function QuickStartPage() { + // Clerk onboarding wizard. Self-hosted has no ClerkProvider and no plan gate. + if (!isClerkAuthEnabled) { + return + } + return +} + +function QuickStartPageInner() { const { orgId } = useAuth() const { activeStep, From a23acfdebe23a2fc6f01b0814dee1b193cfb9cf2 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 18:39:22 +0800 Subject: [PATCH 5/6] fix(web): skip Autumn billing customer fetch in self-hosted mode GET /internal/billing/customer is 500 BillingNotConfiguredError without AUTUMN_SECRET_KEY. Self-hosted has no Stripe/Autumn; settings nav still subscribed useMapleCustomer. Disable the atom in that mode. --- apps/web/src/hooks/use-maple-customer.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/use-maple-customer.ts b/apps/web/src/hooks/use-maple-customer.ts index f6f3616b8..89e868ddd 100644 --- a/apps/web/src/hooks/use-maple-customer.ts +++ b/apps/web/src/hooks/use-maple-customer.ts @@ -2,11 +2,18 @@ import type { BillingCustomer } from "@maple/domain/http" import { Result, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom } from "@/lib/services/atoms/billing-atoms" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" type UseMapleCustomerOptions = { queryOptions?: { enabled?: boolean } } +const SELF_HOSTED_CUSTOMER = { + data: undefined, + isLoading: false, + error: undefined, +} as const + /** * Thin accessor over `billingCustomerAtom` for the incidental consumers (app * shell, banners, nav, onboarding) that just need `{ data, isLoading, error }`. @@ -14,11 +21,17 @@ type UseMapleCustomerOptions = { * * `enabled: false` (used by `__root` before an org is active) swaps in a disabled * atom so the customer fetch never fires for signed-out / org-less sessions. + * + * Self-hosted has no Autumn/Stripe: billing is not a product surface, and + * `GET /internal/billing/customer` answers 500 `BillingNotConfiguredError` + * when `AUTUMN_SECRET_KEY` is unset. Never subscribe the atom in that mode. */ export function useMapleCustomer(options?: UseMapleCustomerOptions) { - const enabled = options?.queryOptions?.enabled ?? true + const enabled = isClerkAuthEnabled && (options?.queryOptions?.enabled ?? true) const result = useAtomValue(enabled ? billingCustomerAtom : disabledResultAtom()) + if (!isClerkAuthEnabled) return SELF_HOSTED_CUSTOMER + return { data: Result.isSuccess(result) ? result.value : undefined, isLoading: Result.isInitial(result), From 8b92a9c3aef9b1a7626fc94deeada6ec66a63605 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 30 Aug 2026 21:47:46 +0800 Subject: [PATCH 6/6] feat(deploy): slim the celld image and migrate without drizzle-kit turbo prune the Worker graph, drop oxlint/alchemy/wrangler from the runtime, and apply Postgres migrations with drizzle-orm so the image does not need drizzle-kit. Co-Authored-By: Grok --- .dockerignore | 5 ++ deploy/celld-self-host/compose.yml | 2 +- .../celld-self-host/docker/Dockerfile.celld | 83 +++++++++++++------ .../docker/entrypoint-celld.sh | 11 +-- deploy/celld-self-host/k8s/migrate.yaml | 2 +- docs/celld-self-host.md | 23 +++++ packages/db/package.json | 1 + packages/db/scripts/migrate-pg.ts | 22 +++++ 8 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 packages/db/scripts/migrate-pg.ts diff --git a/.dockerignore b/.dockerignore index e223515f1..d261d0b3e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,11 @@ target **/target apps/api/.data apps/api/.data/** +apps/ios +apps/web/dist +**/.celld +**/.wrangler +**/.turbo .env .env.* **/.env diff --git a/deploy/celld-self-host/compose.yml b/deploy/celld-self-host/compose.yml index a60bfdac3..6ef304ad7 100644 --- a/deploy/celld-self-host/compose.yml +++ b/deploy/celld-self-host/compose.yml @@ -186,7 +186,7 @@ services: set -euo pipefail export PATH="/usr/local/bin:/root/.bun/bin:$$PATH" cd /app - bun run --cwd packages/db db:migrate + bun run --cwd packages/db db:migrate:pg bun run --cwd packages/clickhouse-cli start apply \ --url="$$CLICKHOUSE_URL" --user="$$CLICKHOUSE_USER" \ --password="$$CLICKHOUSE_PASSWORD" --database="$$CLICKHOUSE_DATABASE" diff --git a/deploy/celld-self-host/docker/Dockerfile.celld b/deploy/celld-self-host/docker/Dockerfile.celld index 9ae9986c8..f876d9721 100644 --- a/deploy/celld-self-host/docker/Dockerfile.celld +++ b/deploy/celld-self-host/docker/Dockerfile.celld @@ -1,47 +1,80 @@ -# celld Worker runtime. Same Maple Worker sources as Cloudflare; no alchemy. -# Build from repo root: docker build -f deploy/celld-self-host/docker/Dockerfile.celld . +# celld Worker runtime. Production graph only: api + electric-sync + alerting +# (+ clickhouse-cli / db for the migrate job). Build from repo root: +# docker build -f deploy/celld-self-host/docker/Dockerfile.celld . + FROM oven/bun:1.4.0 AS base WORKDIR /app +FROM base AS pruner +COPY . . +RUN bunx turbo prune \ + @maple/api \ + @maple/alerting \ + @maple/electric-sync \ + @maple/clickhouse-cli \ + --docker --out-dir /app/out + +FROM base AS deps +COPY --from=pruner /app/out/json/ ./ +COPY --from=pruner /app/out/bun.lock ./bun.lock +COPY bunfig.toml ./ +COPY patches ./patches +# turbo prune keeps the root package.json, including oxlint/alchemy/knip. +# Those are not Worker runtime. Drop them before bun install. +RUN bun -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); p.devDependencies={}; p.scripts={}; fs.writeFileSync("package.json", JSON.stringify(p,null,2));' +RUN bun install --ignore-scripts || bun install --ignore-scripts + +FROM deps AS build +COPY --from=pruner /app/out/full/ ./ +# `out/full` restores the original root package.json; strip tooling again. +RUN bun -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); p.devDependencies={}; p.scripts={}; fs.writeFileSync("package.json", JSON.stringify(p,null,2));' +# Re-install with full sources so workspace bins (tsdown) resolve, then emit +# dist/ for packages whose package.json exports point at dist/, not src/. +RUN bun install --ignore-scripts || bun install --ignore-scripts +RUN bun run --cwd lib/clickhouse-builder build && \ + bun run --cwd packages/effect-sdk build + +# Drop workspace devDependencies (wrangler/workerd/miniflare/vitest). +# Postgres migrate uses packages/db/scripts/migrate-pg.ts (drizzle-orm), not drizzle-kit. +FROM build AS prod +RUN rm -rf node_modules && bun install --production --ignore-scripts + +FROM oven/bun:1.4.0-slim AS runtime +WORKDIR /app + ARG CELLD_VERSION=v0.4.0 -ARG TARGETARCH=arm64 +ARG TARGETARCH +ARG ESBUILD_VERSION=0.24.2 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl gzip \ && rm -rf /var/lib/apt/lists/* RUN set -euo pipefail; \ - case "$TARGETARCH" in \ - amd64) asset=celld-x86_64-unknown-linux-gnu.gz ;; \ - arm64) asset=celld-aarch64-unknown-linux-gnu.gz ;; \ - *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + arch="${TARGETARCH:-}"; \ + if [ -z "$arch" ]; then \ + case "$(uname -m)" in \ + aarch64|arm64) arch=arm64 ;; \ + x86_64|amd64) arch=amd64 ;; \ + *) echo "unsupported arch $(uname -m)" >&2; exit 1 ;; \ + esac; \ + fi; \ + case "$arch" in \ + amd64) asset=celld-x86_64-unknown-linux-gnu.gz; plat=linux-x64 ;; \ + arm64) asset=celld-aarch64-unknown-linux-gnu.gz; plat=linux-arm64 ;; \ + *) echo "unsupported TARGETARCH=$arch" >&2; exit 1 ;; \ esac; \ curl -fsSL "https://github.com/denoland/celld/releases/download/${CELLD_VERSION}/${asset}" \ | gzip -d > /usr/local/bin/celld; \ chmod +x /usr/local/bin/celld; \ - celld --version - -COPY package.json bun.lock bunfig.toml turbo.json ./ -COPY apps ./apps -COPY packages ./packages -COPY lib ./lib -COPY patches ./patches - -ARG ESBUILD_VERSION=0.24.2 -RUN set -euo pipefail; \ - case "$TARGETARCH" in \ - amd64) plat=linux-x64 ;; \ - arm64) plat=linux-arm64 ;; \ - *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ - esac; \ curl -fsSL "https://registry.npmjs.org/@esbuild/${plat}/-/${plat}-${ESBUILD_VERSION}.tgz" \ | tar -xz -C /tmp; \ install -m 0755 /tmp/package/bin/esbuild /usr/local/bin/esbuild; \ + rm -rf /tmp/package; \ + celld --version; \ esbuild --version -# Workspace install can flake on npm (pglite/alchemy are unused at celld runtime). -RUN bun install || bun install - +COPY --from=prod /app /app COPY deploy/celld-self-host/docker/entrypoint-celld.sh /entrypoint-celld.sh RUN chmod +x /entrypoint-celld.sh diff --git a/deploy/celld-self-host/docker/entrypoint-celld.sh b/deploy/celld-self-host/docker/entrypoint-celld.sh index b5df77dbe..bcc186a80 100755 --- a/deploy/celld-self-host/docker/entrypoint-celld.sh +++ b/deploy/celld-self-host/docker/entrypoint-celld.sh @@ -1,7 +1,7 @@ -#!/usr/bin/env bash +#!/bin/sh # Production celld: deploy the Worker into the fleet bucket, then serve it. # Not `celld dev` — that uses PROJECT/.celld/dev and shares nothing with prod. -set -euo pipefail +set -eu APP_DIR="${CELLD_APP_DIR:?CELLD_APP_DIR required}" PORT="${CELLD_PORT:?CELLD_PORT required}" @@ -14,7 +14,7 @@ REGION="${AWS_REGION:-us-east-1}" cd "$APP_DIR" export PATH="/usr/local/bin:/root/.bun/bin:${PATH}" export CELLD_ESBUILD="${CELLD_ESBUILD:-$(command -v esbuild)}" -if [[ -z "${CELLD_ESBUILD}" ]]; then +if [ -z "${CELLD_ESBUILD}" ]; then echo "celld-entrypoint: esbuild not on PATH" >&2 exit 1 fi @@ -27,11 +27,12 @@ mkdir -p "$CELLD_WATCH" echo "celld-entrypoint: deploy $CONFIG → $BUCKET ($ENDPOINT)" "$CELLD_BIN" deploy "$CONFIG" --bucket "$BUCKET" --endpoint "$ENDPOINT" --region "$REGION" -echo "celld-entrypoint: listen 0.0.0.0:${PORT}" +LISTEN_HOST="${CELLD_LISTEN_HOST:-0.0.0.0}" +echo "celld-entrypoint: listen ${LISTEN_HOST}:${PORT}" exec "$CELLD_BIN" \ --bucket "$BUCKET" \ --endpoint "$ENDPOINT" \ --region "$REGION" \ - --listen "0.0.0.0:${PORT}" \ + --listen "${LISTEN_HOST}:${PORT}" \ --internal-listen "127.0.0.1:0" \ --trust-forwarded-headers diff --git a/deploy/celld-self-host/k8s/migrate.yaml b/deploy/celld-self-host/k8s/migrate.yaml index a36e70f93..a52b73d11 100644 --- a/deploy/celld-self-host/k8s/migrate.yaml +++ b/deploy/celld-self-host/k8s/migrate.yaml @@ -28,7 +28,7 @@ spec: set -euo pipefail export PATH="/usr/local/bin:/root/.bun/bin:$PATH" cd /app - bun run --cwd packages/db db:migrate + bun run --cwd packages/db db:migrate:pg bun run --cwd packages/clickhouse-cli start apply \ --url="$CLICKHOUSE_URL" \ --user="$CLICKHOUSE_USER" \ diff --git a/docs/celld-self-host.md b/docs/celld-self-host.md index e1646ca66..a4fbdb7fd 100644 --- a/docs/celld-self-host.md +++ b/docs/celld-self-host.md @@ -282,6 +282,29 @@ deploy/celld-self-host/ k8s/ ``` +### Images + +`Dockerfile.celld` is a production graph, not a copy of the monorepo: + +1. `turbo prune @maple/api @maple/alerting @maple/electric-sync @maple/clickhouse-cli` +2. strip root tooling (`oxlint` / `alchemy` / `knip`) +3. build `clickhouse-builder` + `effect-sdk` `dist/` +4. `bun install --production` (no wrangler / workerd / vitest) +5. slim runtime: `oven/bun:1.4.0-slim` + celld + esbuild + +Migrate uses `bun run --cwd packages/db db:migrate:pg` (`drizzle-orm` migrator), +not `drizzle-kit`, so the runtime image does not need that devDependency. + +Measured on arm64 (one image; api/sync/alerting share layers): + +| Image | Approx | +| ----------------------------- | -------------------------------------------------------- | +| `maple-celld` | 559 MB (was 3.35 GB with a full-workspace `bun install`) | +| `maple-web` | 75 MB | +| `maple-pg-ws-proxy` | 185 MB (`bun` slim base) | +| `maple-otel` | 29 MB | +| ClickHouse / Electric / Caddy | upstream | + ## VPS notes A VPS bring-up is the data plane plus one celld process per public Worker: diff --git a/packages/db/package.json b/packages/db/package.json index 102f03d3b..f261c43ee 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,6 +14,7 @@ "test": "vitest run --passWithNoTests", "db:generate": "drizzle-kit generate --config ./drizzle.config.ts", "db:migrate": "drizzle-kit migrate --config ./drizzle.config.ts", + "db:migrate:pg": "bun scripts/migrate-pg.ts", "db:ensure-electric-publication": "bun scripts/ensure-electric-publication.ts", "db:push": "drizzle-kit push --config ./drizzle.config.ts", "db:studio": "drizzle-kit studio --config ./drizzle.config.ts", diff --git a/packages/db/scripts/migrate-pg.ts b/packages/db/scripts/migrate-pg.ts new file mode 100644 index 000000000..bf7db21d3 --- /dev/null +++ b/packages/db/scripts/migrate-pg.ts @@ -0,0 +1,22 @@ +/** + * Apply bundled drizzle SQL to a real Postgres URL. + * Used by the celld self-host migrate Job so the runtime image does not need + * drizzle-kit (a db devDependency). Local/CI still use `db:migrate`. + */ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { drizzle } from "drizzle-orm/postgres-js" +import { migrate } from "drizzle-orm/postgres-js/migrator" +import postgres from "postgres" + +const url = process.env.DATABASE_URL?.trim() || process.env.MAPLE_PG_URL?.trim() +if (!url) { + console.error("migrate-pg: DATABASE_URL or MAPLE_PG_URL is required") + process.exit(1) +} + +const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle") +const sql = postgres(url, { max: 1 }) +await migrate(drizzle(sql), { migrationsFolder }) +await sql.end({ timeout: 5 }) +console.log("migrate-pg: applied", migrationsFolder)